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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UBHYPdeQYy2m1GVJp6b12
This commit is contained in:
Jules
2026-09-28 17:06:49 +02:00
co-authored by Claude Fable 5.1
commit 69809df5d1
123 changed files with 13225 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js';
export interface AdequationDebug {
score: number;
scoreurs: Record<string, {
score: number;
poids: number;
}>;
avertissement?: string;
}
/**
* Calcule le score d'adéquation contextuelle d'une typologie [0..1].
*
* @param typologies_selectionnees — sélections déjà faites (pour score_couplage_disponible)
* @returns score ∈ [0..1] ; en debug, { score, scoreurs }
*/
export declare function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees?: string[], debug?: false): number;
export declare function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees: string[], debug: true): AdequationDebug;
+59
View File
@@ -0,0 +1,59 @@
// adequation.ts — adequation_contexte(typo, inputs, poste, contexte_derived) → [0..1]
// Spec: V2-algo-spec-FONCTIONS.md §1 — origine calculs/src/lib/autonomie/adequation.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
// console.warn de debug devient un champ `avertissement` du retour debug.
import { clamp01 } from './calibrage.js';
import { score_pluviometrie, score_climat_chauffage, score_climat_meridional, score_taille_foyer, score_surface_toit, score_orientation_toit, score_surface_parcelle, score_temps_dispo, score_metres_haie, score_couplage_disponible, } from './scoreurs.js';
// Catalogue de dispatch des scoreurs par nom de critère
const SCOREURS = {
pluviometrie: score_pluviometrie,
climat_chauffage: score_climat_chauffage,
climat_meridional: score_climat_meridional,
taille_foyer: score_taille_foyer,
surface_toit: score_surface_toit,
orientation_toit: score_orientation_toit,
surface_parcelle: score_surface_parcelle,
temps_dispo: score_temps_dispo,
metres_haie_possibles: score_metres_haie,
couplage_disponible: score_couplage_disponible,
};
export function adequation_contexte(typo, inputs, poste, contexte_derived, reglages, typologies_selectionnees = [], debug = false) {
const matrix = reglages.config.adequation_weights?.[poste.code];
if (!matrix) {
if (debug)
return { score: 0.5, scoreurs: {}, avertissement: `[adequation_contexte] Pas de matrice pour poste "${poste.code}" — fallback 0.5` };
return 0.5;
}
let weighted_sum = 0;
let total_weight = 0;
const debug_scores = {};
for (const [critere, poids] of Object.entries(matrix)) {
if (poids === 0 || !SCOREURS[critere])
continue;
const scoreur = SCOREURS[critere];
let s;
if (critere === 'couplage_disponible') {
s = scoreur(typo, inputs, contexte_derived, poste, typologies_selectionnees, reglages);
}
else {
s = scoreur(typo, inputs, contexte_derived, poste, [], reglages);
}
weighted_sum += s * poids;
total_weight += poids;
if (debug)
debug_scores[critere] = { score: +s.toFixed(3), poids };
}
let score = total_weight > 0 ? weighted_sum / total_weight : 0.5;
// §1.4 Bonus / malus globaux
if (typo.flag_diy && inputs.temps_dispo === 3)
score *= 1.3;
if (typo.complexite_pose === 3 && inputs.temps_dispo === 1)
score *= 0.5;
if (!typo.accessible_fr)
score *= 0.6;
score = clamp01(score);
if (debug) {
return { score, scoreurs: debug_scores };
}
return score;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Autodiag, Contexte, EtapeDebug, InputsSimulation, LigneClimat, Option, Poste, Reglages, Selection, SortieSelection, Statut } from './types.js';
export interface SelectionFinalisee {
axes: Selection;
total_eur_ht: number;
status: Statut;
message_status?: string;
couplages: string[];
debug_reajust: string[] | null;
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export declare function finaliseSelection(selection: Selection, budget_total: number, options: Option[], postes: Poste[], inputs: Autodiag, contexte: Contexte, reglages: Reglages, debugMode?: boolean, debug?: {
steps: EtapeDebug[];
}): SelectionFinalisee;
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export declare function selectionner(inputs: InputsSimulation, options: Option[], postes: Poste[], climat: LigneClimat[], reglages: Reglages, debugMode?: boolean): SortieSelection;
+135
View File
@@ -0,0 +1,135 @@
// algo.ts — module principal orchestrateur des 6 étapes
// Spec: V2-algo-spec.md + V2-algo-spec-FONCTIONS.md — origine calculs/src/lib/autonomie/algo.js.
// Zero-dep, isomorphe navigateur/Node.
//
// Corrections LOT P1.0 : plus d'initConfig (les réglages passent en paramètre) ;
// le contexte vient de commun/contexte.ts (Corse 2A/2B et DOM 971-976 corrigés,
// une seule version) ; la sortie porte en plus `contexte` et `avertissements`.
// C'est le pipeline COMPLET d'origine (couplages et réajustement globaux), celui
// que calculs.trans-former.fr consommera en P1.5 sans changement de résultat.
//
// Découpage par axe (LOT P1.0) : la queue du pipeline (étapes 4 à 6) est
// isolée dans `finaliseSelection`, que les moteurs d'axe réutilisent telle
// quelle sur une sélection à un seul axe. Un moteur d'axe est donc, par
// construction, le mode « un seul axe actif » de `selectionner`.
import { deriveContexte } from '../../commun/contexte.js';
import { pondereAxes } from './pondere-axes.js';
import { alloueBudget } from './alloue-budget.js';
import { selectionnePoste } from './selectionne-poste.js';
import { appliqueCouplages } from './couplages.js';
import { reajusteBudget } from './reajuste.js';
const AXES_CODE = ['eau', 'energie', 'alim'];
function sommeAxes(axes) {
return Math.round(AXES_CODE.reduce((s, axe) => s + ((axes[axe] && axes[axe].sous_total_eur_ht) || 0), 0));
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export function finaliseSelection(selection, budget_total, options, postes, inputs, contexte, reglages, debugMode = false, debug) {
const config = reglages.config;
// --- Étape 4 : couplages contraints ---
const apres_couplages = appliqueCouplages(selection, options, postes, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 4, label: 'couplages', log: apres_couplages.couplages_appliques });
// --- Étape 5 : réajustement budget ---
const final_pre = reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 5, label: 'reajustement', status: final_pre.status, total: final_pre.total, log: final_pre.debug_reajust });
// Re-passe couplages post-réajustement, avec réabsorption des coûts forcés :
// le CAS B (underspend) peut ajouter ou upgrader des typologies ayant des
// couplages_requis. La passe couplages force les requis manquants
// (ex: bois_poele_hydro → bois_conduit_fumee) — coûts contraints, inclus
// dans le total final (comportement intentionnel).
// Correctif V3-1 (recalibrage) : ces ajouts pouvaient faire franchir le seuil
// d'overrun SANS rééquilibrage ni changement de status (profil A : +8,9 % avec
// status OK). Boucle bornée à 2 passes : couplages → si overrun, réajuster →
// couplages. CAS A ne fait que réduire, la convergence est garantie ; si le
// seuil reste franchi, reajusteBudget pose BUDGET_INSUFFISANT (status honnête).
const tol_over = (config.tolerances && config.tolerances.budget_overrun_pct) || 5;
const seuil_over = budget_total * (1 + tol_over / 100);
let etat = {
axes: final_pre.axes,
couplages_appliques: [...(final_pre.couplages_appliques || [])],
};
let status_final = final_pre.status;
let message_final = final_pre.message;
for (let passe = 0; passe < 2; passe++) {
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false);
etat = {
axes: avec.axes,
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
};
if (sommeAxes(etat.axes) <= seuil_over)
break;
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
etat = {
axes: re.axes,
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
};
status_final = re.status;
message_final = re.message;
}
return {
axes: etat.axes,
total_eur_ht: sommeAxes(etat.axes),
status: status_final,
message_status: message_final || undefined,
couplages: etat.couplages_appliques,
debug_reajust: final_pre.debug_reajust,
};
}
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export function selectionner(inputs, options, postes, climat, reglages, debugMode = false) {
const debug = { steps: [] };
const config = reglages.config;
// --- Étape 0 : dériver le contexte depuis code postal ---
const ctx = deriveContexte(inputs.code_postal, climat, reglages.coefficients);
const contexte = ctx.sorties;
if (debugMode)
debug.steps.push({ etape: 0, label: 'contexte', data: contexte });
// --- Étape 1 : pondération axes ---
const poids_debug = debugMode ? pondereAxes(inputs, contexte, config, true) : null;
const poids = poids_debug ? poids_debug.poids : pondereAxes(inputs, contexte, config, false);
if (poids_debug)
debug.steps.push({ etape: 1, label: 'ponderation', poids, log: poids_debug.log });
// --- Étape 2 : allocation budget ---
const budgets = alloueBudget(inputs.budget_eur, poids);
if (debugMode)
debug.steps.push({ etape: 2, label: 'allocation', budgets });
// --- Étape 3 : sélection par axe ---
const selection = {};
for (const axe of AXES_CODE) {
const res = selectionnePoste(budgets[axe], inputs, contexte, options, postes, axe, reglages, debugMode);
res.poids = poids[axe];
selection[axe] = res;
}
if (debugMode)
debug.steps.push({ etape: 3, label: 'selection', summary: Object.fromEntries(AXES_CODE.map((a) => [a, selection[a].sous_total_eur_ht])) });
// --- Étapes 4 à 6 ---
const fin = finaliseSelection(selection, inputs.budget_eur, options, postes, inputs, contexte, reglages, debugMode, debugMode ? debug : undefined);
const ecart = inputs.budget_eur > 0 ? ((fin.total_eur_ht - inputs.budget_eur) / inputs.budget_eur) * 100 : 0;
return {
axes: fin.axes,
total_eur_ht: fin.total_eur_ht,
budget_cible: inputs.budget_eur,
ecart_budget_pct: +ecart.toFixed(2),
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
contexte,
avertissements: ctx.avertissements,
debug: debugMode ? debug : undefined,
};
}
+9
View File
@@ -0,0 +1,9 @@
import type { PoidsAxes } from './types.js';
/**
* Alloue le budget total entre les 3 axes selon leurs poids.
*
* @param budget_total — EUR
* @param poids — somme = 100
* @returns en EUR
*/
export declare function alloueBudget(budget_total: number, poids: PoidsAxes): PoidsAxes;
+16
View File
@@ -0,0 +1,16 @@
// alloue-budget.ts — Étape 2 : allocation budget par axe
// Spec: V2-algo-spec.md §Étape 2 — origine calculs/src/lib/autonomie/alloue-budget.js, inchangé.
/**
* Alloue le budget total entre les 3 axes selon leurs poids.
*
* @param budget_total — EUR
* @param poids — somme = 100
* @returns en EUR
*/
export function alloueBudget(budget_total, poids) {
return {
eau: (budget_total * poids.eau) / 100,
energie: (budget_total * poids.energie) / 100,
alim: (budget_total * poids.alim) / 100,
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { AlgoConfig, Axe } from './types.js';
import { AXES } from './types.js';
export type AxesActifs = Partial<Record<Axe, boolean>>;
/**
* Transforme algo-config pour neutraliser les axes désactivés.
* - poids_axes_base[axe] = 0 pour tout axe inactif
* - tous les deltas de modulateurs référençant un axe inactif sont retirés
* - marqueur `_axes_desactives` (lu par reajuste.ts pour ne pas réinjecter
* des postes optionnels dans un axe éteint lors du CAS B underspend)
*
* @param config — algo-config.json parsé (non muté)
* @returns nouvelle config
*/
export declare function configPourAxes(config: AlgoConfig, actifs: AxesActifs | null | undefined): AlgoConfig;
/**
* Vrai si au moins un axe est actif (0 axe actif = état vide géré par l'UI,
* l'algo ne doit pas tourner).
*/
export declare function auMoinsUnAxe(actifs: AxesActifs | null | undefined): boolean;
export { AXES };
+48
View File
@@ -0,0 +1,48 @@
// axes-actifs.ts — Toggles d'axes (V3 vague 1, M2)
// Produit une config dérivée où les axes désactivés ont un poids nul,
// sans toucher aux modules cœur de l'algo. La désactivation passe
// uniquement par les inputs/config (poids), conformément à la spec.
// Origine calculs/src/lib/autonomie/axes-actifs.js, inchangé (typé).
import { AXES } from './types.js';
/**
* Transforme algo-config pour neutraliser les axes désactivés.
* - poids_axes_base[axe] = 0 pour tout axe inactif
* - tous les deltas de modulateurs référençant un axe inactif sont retirés
* - marqueur `_axes_desactives` (lu par reajuste.ts pour ne pas réinjecter
* des postes optionnels dans un axe éteint lors du CAS B underspend)
*
* @param config — algo-config.json parsé (non muté)
* @returns nouvelle config
*/
export function configPourAxes(config, actifs) {
const desactives = AXES.filter((a) => actifs && actifs[a] === false);
if (desactives.length === 0)
return config;
// Clone profond (config petite, JSON-safe)
const cfg = JSON.parse(JSON.stringify(config));
cfg.poids_axes_base = cfg.poids_axes_base || { eau: 40, energie: 40, alim: 20 };
for (const axe of desactives)
cfg.poids_axes_base[axe] = 0;
// Neutraliser les deltas des modulateurs qui re-donneraient du poids
// à un axe désactivé (ex : zone MED -> eau+10, parcelle grande -> alim+10).
const mod = cfg.modulateurs || {};
for (const key of Object.keys(mod)) {
const val = mod[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
for (const axe of desactives) {
if (axe in val)
delete val[axe];
}
}
}
cfg._axes_desactives = desactives;
return cfg;
}
/**
* Vrai si au moins un axe est actif (0 axe actif = état vide géré par l'UI,
* l'algo ne doit pas tourner).
*/
export function auMoinsUnAxe(actifs) {
return AXES.some((a) => !actifs || actifs[a] !== false);
}
export { AXES };
+4
View File
@@ -0,0 +1,4 @@
export { ZONE_BIOGEO_MAP, zoneRtToClass } from '../../commun/contexte.js';
export declare function clamp01(x: number): number;
export declare function clamp(x: number, min: number, max: number): number;
export declare function roundToStep(value: number, step: number): number;
+19
View File
@@ -0,0 +1,19 @@
// calibrage.ts — helpers numériques du noyau.
//
// Origine : calculs/src/lib/autonomie/calibrage.js. Correction LOT P1.0 : le
// singleton `_config` (initConfig / getConfig) est retiré, la configuration
// passe en paramètre (Reglages) chez les cinq appelants. ORIENTATION_FACTORS et
// DJU_DEFAULTS_BY_ZONE vivent désormais dans commun/coefficients.ts (une seule
// définition, sourcée) ; ZONE_BIOGEO_MAP et zoneRtToClass dans commun/contexte.ts.
export { ZONE_BIOGEO_MAP, zoneRtToClass } from '../../commun/contexte.js';
export function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
export function clamp(x, min, max) {
return Math.max(min, Math.min(max, x));
}
export function roundToStep(value, step) {
if (!step || step <= 0)
return value;
return Math.round(value / step) * step;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Option, Poste, Reglages, Selection } from './types.js';
export interface ResultatCouplages {
axes: Selection;
couplages_appliques: string[];
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export declare function appliqueCouplages(selection_recue: Selection, options: Option[], postes: Poste[] | null | undefined, reglages: Reglages, _debug?: boolean): ResultatCouplages;
+129
View File
@@ -0,0 +1,129 @@
// couplages.ts — Étape 4 : application des couplages contraints
// Spec: V2-algo-spec.md §Étape 4 — origine calculs/src/lib/autonomie/couplages.js.
// Corrections LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; la
// sélection reçue n'est PLUS mutée (copie des axes et de leurs postes, le
// résultat est renvoyé dans `axes`). Tous les appelants d'origine lisaient
// déjà le retour, le comportement est identique.
//
// Note (origine, conservée) : le marqueur `_conflit_avec` de la passe 2 est
// posé sur des copies de postes et n'atteint jamais la sortie ; le
// « downgrade planifié » n'a donc jamais lieu. À corriger en patch, avec test.
import { cout_typologie, dimension_optimale } from './dimension.js';
function copieSelection(selection) {
const copie = {};
for (const axe of Object.keys(selection)) {
const a = selection[axe];
if (!a)
continue;
const ax = { ...a, postes: a.postes.map((p) => ({ ...p })) };
copie[axe] = ax;
}
return copie;
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export function appliqueCouplages(selection_recue, options, postes, reglages, _debug = false) {
const alias = reglages.config.couplages_alias || {};
const selection = copieSelection(selection_recue);
// Construire un mapping poste_id (num) → axe via postes BDD
const posteIdToAxe = {};
if (postes) {
for (const p of postes) {
// Normaliser axe : "alimentation" → "alim"
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
posteIdToAxe[p.Id] = axe_norm;
}
}
function resolveCode(code) {
return alias[code] || code;
}
const axes_list = ['eau', 'energie', 'alim'];
const tous_postes = axes_list.flatMap((axe) => (selection[axe] && selection[axe].postes ? selection[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
const codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie));
const couplages_log = [];
// Passe 1 : couplages_requis
for (const poste of tous_postes) {
const requis_list = poste.couplages_requis || [];
for (const requis_raw of requis_list) {
const requis = resolveCode(requis_raw);
if (!codes_selectionnes.has(requis)) {
// Chercher la typo en BDD (code exact ou alias original)
let typo_requise = options.find((o) => o.code === requis);
if (!typo_requise && requis !== requis_raw) {
typo_requise = options.find((o) => o.code === requis_raw);
}
if (typo_requise) {
// Trouver l'axe via mapping poste_id → axe
const axe_cible = posteIdToAxe[typo_requise.poste_id] || trouverAxeParCode(typo_requise.code);
const cible = axe_cible ? selection[axe_cible] : undefined;
if (axe_cible && cible) {
const dim = dimension_optimale(typo_requise, {}, {}, reglages.coefficients);
const cout = cout_typologie(typo_requise, dim, {});
const cout_force = Math.round(cout || 0);
const unite_dim = dim ? dim.unite : typo_requise.unite || '';
const valeur_dim = dim ? dim.valeur : typo_requise.quantite_default || 1;
cible.postes.push({
poste_id: String(typo_requise.poste_id) + '_couplage',
poste_nom: 'Couplage requis (' + typo_requise.nom + ')',
obligatoire: true,
budget_alloue_poste: cout_force,
typologie_choisie: typo_requise.code,
nom_affichage: typo_requise.nom,
description_pedago: typo_requise.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: cout_force,
score_adequation: 0.8,
score_final: 0,
alternatives: [],
notes_techniques: typo_requise.notes_techniques || '',
flag_diy: typo_requise.flag_diy || false,
complexite_pose: typo_requise.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
force_par_couplage: true,
});
cible.sous_total_eur_ht += cout_force;
codes_selectionnes.add(typo_requise.code);
couplages_log.push('FORCE "' + typo_requise.code + '" (requis par "' + poste.typologie_choisie + '") +' + cout_force + 'EUR dans axe ' + axe_cible);
}
else {
couplages_log.push('WARN axe non trouve pour "' + requis + '" (requis par "' + poste.typologie_choisie + '")');
}
}
else {
couplages_log.push('MANQUANT "' + requis + '" (requis par "' + poste.typologie_choisie + '") — code BDD non trouve');
}
}
}
}
// Passe 2 : couplages_exclus
for (const poste of tous_postes) {
const exclus_list = poste.couplages_exclus || [];
for (const exclu_raw of exclus_list) {
const exclu = resolveCode(exclu_raw);
if (codes_selectionnes.has(exclu)) {
const poste_conflit = tous_postes.find((p) => p.typologie_choisie === exclu);
if (poste_conflit && !poste_conflit.force_par_couplage) {
poste_conflit._conflit_avec = poste.typologie_choisie;
couplages_log.push('CONFLIT "' + exclu + '" avec "' + poste.typologie_choisie + '" — downgrade planifié');
}
}
}
}
return { axes: selection, couplages_appliques: couplages_log };
}
// Fallback heuristique si poste_id absent du mapping
function trouverAxeParCode(code) {
if (/captage|stockage|filtrat|distrib|eg_|stherm_ballon/.test(code))
return 'eau';
if (/^th_|^pv_|^bois_|^pil_|^emet_|ballon_tampon|conduit_fumee/.test(code))
return 'energie';
if (/^etude_|^haute_|^moy_|^basse_/.test(code))
return 'alim';
return 'energie';
}
+27
View File
@@ -0,0 +1,27 @@
import type { TableCoefficients } from '../../commun/coefficients.js';
import type { Autodiag, Contexte, Dimension, Option } from './types.js';
/**
* Calcule la valeur optimale du paramètre dimensionnant pour une typologie.
*
* Particularité ballon_tampon (th_ballon_500) :
* - unite BDD = "forfait" (contrainte schéma NocoDB)
* - prix_par_unite = 1.2 EUR/L
* - parametre_dim.unite = "L" (source de vérité pour l'affichage et le calcul)
* → L'algo utilise toujours parametre_dim.unite quand présent, sinon fallback option.unite
*/
export declare function dimension_optimale(typo: Option, inputs: Partial<Autodiag>, contexte_derived: Partial<Contexte>, coefficients: TableCoefficients): Dimension | null;
/**
* Calcule le coût HT d'une typologie pour une dimension donnée.
*
* Hiérarchie des prix (3 cas BDD) :
* 1. prix_fixe + prix_par_unite × dim.valeur (V2 enrichi, ex: cuve béton, PV)
* 2. prix_par_unite × dim.valeur seul (ex: th_ballon_500)
* 3. prix_mat_min + pose si pro requis (forfaits legacy sans parametre_dim enrichi)
*
* Pour les postes pro (complexite_pose=3) et temps_dispo < 3, ajouter prix_pro_min.
* Fallback final : (prix_mat_min + prix_mat_max) / 2
*
* Les prix sont des DONNÉES de l'option (publiées par calculs) ; la seule
* heuristique du code est « pose pro = 50 % du matériel si prix_pro absent ».
*/
export declare function cout_typologie(typo: Option, dim_result: Dimension | null | undefined, inputs?: Partial<Autodiag> | null): number;
+113
View File
@@ -0,0 +1,113 @@
// dimension.ts — dimension_optimale(typo, inputs, contexte_derived, coefficients) → Dimension | null
// Spec: V2-algo-spec-FONCTIONS.md §2 — origine calculs/src/lib/autonomie/dimension.js.
// Correction LOT P1.0 : facteurs d'orientation et DJU par défaut lus dans la
// table de coefficients passée en paramètre (plus de constante de module).
import { zoneRtToClass, clamp, roundToStep } from './calibrage.js';
import { parseParam } from './types.js';
/**
* Calcule la valeur optimale du paramètre dimensionnant pour une typologie.
*
* Particularité ballon_tampon (th_ballon_500) :
* - unite BDD = "forfait" (contrainte schéma NocoDB)
* - prix_par_unite = 1.2 EUR/L
* - parametre_dim.unite = "L" (source de vérité pour l'affichage et le calcul)
* → L'algo utilise toujours parametre_dim.unite quand présent, sinon fallback option.unite
*/
export function dimension_optimale(typo, inputs, contexte_derived, coefficients) {
const param = parseParam(typo.parametre_dim);
if (!param)
return null;
const strategie = param.strategie || 'fixe';
let valeur;
switch (strategie) {
case 'par_personne': {
const base = param.base != null ? param.base : 0;
const coef = param.coef != null ? param.coef : 1;
valeur = base + (inputs.nb_personnes || 4) * coef;
break;
}
case 'par_surface_toit': {
const table = coefficients.orientation_pv.valeur;
const orient = (inputs.orientation_toit ? table[inputs.orientation_toit] : undefined) || 0.8;
const coef = param.coef != null ? param.coef : 0.1;
valeur = (inputs.surface_toit_m2 || 0) * coef * orient;
break;
}
case 'par_surface_parcelle': {
const reserve = param.reserve_construit != null ? param.reserve_construit : 100;
const utile = Math.max(0, (inputs.surface_parcelle_m2 || 0) - reserve);
const coef = param.coef != null ? param.coef : 0.05;
valeur = utile * coef;
break;
}
case 'par_dju': {
const zone_class = zoneRtToClass(contexte_derived.zone_climatique_rt);
const dju = contexte_derived.dju || coefficients.dju_defaut_par_classe_rt.valeur[zone_class] || 2200;
const coef = param.coef != null ? param.coef : 0.005;
valeur = dju * coef;
break;
}
case 'par_metres_haie': {
const coef = param.coef != null ? param.coef : 1.0;
valeur = (inputs.metres_haie_possibles || 0) * coef;
break;
}
case 'fixe':
default:
valeur = param.default != null ? param.default : 1;
break;
}
// Clamp + arrondi step
const min = param.min != null ? param.min : 0;
const max = param.max != null ? param.max : Infinity;
const step = param.step != null ? param.step : 0;
valeur = clamp(valeur, min, max);
valeur = roundToStep(valeur, step);
// Unite : parametre_dim.unite prioritaire sur option.unite (cas ballon_tampon)
const unite = param.unite || typo.unite || '';
return { valeur, unite, strategie };
}
/**
* Calcule le coût HT d'une typologie pour une dimension donnée.
*
* Hiérarchie des prix (3 cas BDD) :
* 1. prix_fixe + prix_par_unite × dim.valeur (V2 enrichi, ex: cuve béton, PV)
* 2. prix_par_unite × dim.valeur seul (ex: th_ballon_500)
* 3. prix_mat_min + pose si pro requis (forfaits legacy sans parametre_dim enrichi)
*
* Pour les postes pro (complexite_pose=3) et temps_dispo < 3, ajouter prix_pro_min.
* Fallback final : (prix_mat_min + prix_mat_max) / 2
*
* Les prix sont des DONNÉES de l'option (publiées par calculs) ; la seule
* heuristique du code est « pose pro = 50 % du matériel si prix_pro absent ».
*/
export function cout_typologie(typo, dim_result, inputs) {
const prix_fixe = typo.prix_fixe != null ? typo.prix_fixe : 0;
const prix_par_unite = typo.prix_par_unite;
const prix_mat_min = typo.prix_mat_min != null ? typo.prix_mat_min : 0;
const prix_mat_max = typo.prix_mat_max != null ? typo.prix_mat_max : prix_mat_min;
const prix_pro_min = typo.prix_pro_min != null ? typo.prix_pro_min : 0;
// Cas 1 & 2 : prix_par_unite disponible (schéma V2 enrichi)
if (prix_par_unite != null && prix_par_unite > 0) {
const dim_val = dim_result ? dim_result.valeur : typo.quantite_default || 1;
const base = prix_fixe + prix_par_unite * dim_val;
// Forfait pro si complexite=3 et temps_dispo=1
if (typo.complexite_pose === 3 && inputs && inputs.temps_dispo === 1 && prix_pro_min > 0) {
return Math.max(base, prix_pro_min);
}
return base;
}
// Cas 3 : forfait legacy (prix_mat_min/max seulement)
if (prix_mat_min > 0) {
const mid_mat = (prix_mat_min + prix_mat_max) / 2;
// Si complexite_pose = 3 et pose pro : ajouter coût pose pro
if (typo.complexite_pose === 3) {
const pose = prix_pro_min > 0 ? prix_pro_min : mid_mat * 0.5;
return mid_mat + pose;
}
// complexite 1-2 : coût mat seul (DIY inclus)
return mid_mat;
}
// Fallback ultime : 0 (sécurité, ne devrait pas arriver)
return 0;
}
+18
View File
@@ -0,0 +1,18 @@
export { selectionner } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js';
export type { AdequationDebug } from './adequation.js';
export { pondereAxes } from './pondere-axes.js';
export type { PoidsDebug } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js';
export type { ResultatCouplages } from './couplages.js';
export { reajusteBudget } from './reajuste.js';
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js';
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
export type { AxesActifs } from './axes-actifs.js';
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
export * from './scoreurs.js';
export { AXES, parseJsonArr, parseParam } from './types.js';
export type { Axe, Autodiag, InputsSimulation, Poste, Option, ParamDim, AlgoConfig, Reglages, Dimension, Alternative, PosteResultat, AxeResultat, Selection, Statut, PoidsAxes, EtapeDebug, SortieSelection, Contexte, LigneClimat, Orientation, } from './types.js';
+14
View File
@@ -0,0 +1,14 @@
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
// pipeline complet (celui de calculs.trans-former.fr), sans singleton ni I/O.
export { selectionner } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js';
export { pondereAxes } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js';
export { reajusteBudget } from './reajuste.js';
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
export * from './scoreurs.js';
export { AXES, parseJsonArr, parseParam } from './types.js';
+13
View File
@@ -0,0 +1,13 @@
import type { AlgoConfig, Autodiag, Contexte, PoidsAxes } from './types.js';
export interface PoidsDebug {
poids: PoidsAxes;
log: string[];
}
/**
* Calcule les poids des 3 axes (eau/energie/alim) selon le contexte utilisateur.
* Base 40/40/20, modulations documentées dans algo-config.json § modulateurs.
*
* @returns somme = 100 ; en debug, { poids, log }
*/
export declare function pondereAxes(inputs: Autodiag, contexte_derived: Pick<Contexte, 'zone_biogeo' | 'pluviometrie_mm'>, config: AlgoConfig, debug?: false): PoidsAxes;
export declare function pondereAxes(inputs: Autodiag, contexte_derived: Pick<Contexte, 'zone_biogeo' | 'pluviometrie_mm'>, config: AlgoConfig, debug: true): PoidsDebug;
+91
View File
@@ -0,0 +1,91 @@
// pondere-axes.ts — Étape 1 : pondération des axes selon contexte
// Spec: V2-algo-spec.md §Étape 1 — origine calculs/src/lib/autonomie/pondere-axes.js.
// Correction LOT P1.0 : `config` en paramètre (plus de getConfig()).
export function pondereAxes(inputs, contexte_derived, config, debug = false) {
const base = config.poids_axes_base ?? { eau: 40, energie: 40, alim: 20 };
const mod = (config.modulateurs ?? {});
const poids = { eau: base.eau, energie: base.energie, alim: base.alim };
const log = debug ? [] : null;
// SI surface_toit < seuil → poids_energie -= 15, redistribuer eau+5 alim+10
const toit_seuil = mod.toit_petit_seuil_m2 ?? 30;
if ((inputs.surface_toit_m2 ?? 0) < toit_seuil) {
const delta = mod.toit_petit_delta ?? { energie: -15, eau: 5, alim: 10 };
poids.energie += delta.energie ?? 0;
poids.eau += delta.eau ?? 0;
poids.alim += delta.alim ?? 0;
if (log)
log.push(`toit < ${toit_seuil}m² → Δenergie${delta.energie}, Δeau+${delta.eau}, Δalim+${delta.alim}`);
}
// SI surface_toit > 100 m² → poids_energie += 5
if ((inputs.surface_toit_m2 ?? 0) > 100) {
poids.energie += 5;
if (log)
log.push('toit > 100m² → Δenergie+5');
}
// SI nb_personnes >= 5 → poids_eau += 10, poids_alim += 5, poids_energie -= 15
const famille_seuil = mod.famille_grande_seuil ?? 5;
if ((inputs.nb_personnes ?? 0) >= famille_seuil) {
const delta = mod.famille_grande_delta ?? { eau: 10, alim: 5, energie: -15 };
poids.eau += delta.eau ?? 0;
poids.alim += delta.alim ?? 0;
poids.energie += delta.energie ?? 0;
if (log)
log.push(`nb_pers >= ${famille_seuil} → Δeau+${delta.eau}, Δalim+${delta.alim}, Δenergie${delta.energie}`);
}
// SI zone_biogeo == 'MED' → poids_eau += 10
if (contexte_derived.zone_biogeo === 'MED') {
const delta = mod.biogeo_med_delta ?? { eau: 10 };
poids.eau += delta.eau ?? 0;
if (log)
log.push(`zone MED → Δeau+${delta.eau}`);
}
// SI pluviometrie_mm < 600 → poids_eau += 5
const pluvio_seuil = mod.pluvio_basse_seuil_mm ?? 600;
if ((contexte_derived.pluviometrie_mm ?? 800) < pluvio_seuil) {
const delta = mod.pluvio_basse_delta ?? { eau: 5 };
poids.eau += delta.eau ?? 0;
if (log)
log.push(`pluvio < ${pluvio_seuil}mm → Δeau+${delta.eau}`);
}
// SI surface_parcelle < 300 m² → poids_alim = 0
const parcelle_petite = mod.parcelle_petite_seuil_m2 ?? 300;
if ((inputs.surface_parcelle_m2 ?? 0) < parcelle_petite) {
poids.alim = 0;
if (log)
log.push(`parcelle < ${parcelle_petite}m² → alim=0`);
}
// SI surface_parcelle >= 1000 m² ET temps_dispo == 3 → poids_alim += 10
const parcelle_grande = mod.parcelle_grande_seuil_m2 ?? 1000;
if ((inputs.surface_parcelle_m2 ?? 0) >= parcelle_grande && inputs.temps_dispo === 3) {
const delta = mod.parcelle_grande_temps3_delta ?? { alim: 10 };
poids.alim += delta.alim ?? 0;
if (log)
log.push(`parcelle >= ${parcelle_grande}m² + temps=3 → Δalim+${delta.alim}`);
}
// Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) :
// marqueur posé par axes-actifs.ts (configPourAxes). Les modulations
// hardcodées ci-dessus (ex : toit > 100 m² -> energie +5) ne doivent pas
// réactiver un axe éteint. Absent = comportement historique inchangé.
const desactives = config._axes_desactives || [];
for (const axe of desactives) {
if (axe in poids && poids[axe] !== 0) {
poids[axe] = 0;
if (log)
log.push('axe ' + axe + ' désactivé (toggle UI) -> poids 0');
}
}
// Normalisation : somme = 100
const total = poids.eau + poids.energie + poids.alim;
if (total !== 100 && total > 0) {
poids.eau = Math.round((poids.eau / total) * 100);
poids.energie = Math.round((poids.energie / total) * 100);
poids.alim = 100 - poids.eau - poids.energie; // garantie somme exacte
}
// Garde-fou : pas de poids négatif
poids.eau = Math.max(0, poids.eau);
poids.energie = Math.max(0, poids.energie);
poids.alim = Math.max(0, poids.alim);
if (debug)
return { poids, log: log ?? [] };
return poids;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Autodiag, Contexte, Option, Poste, Reglages, Selection, Statut } from './types.js';
export interface EntreeReajuste {
axes: Selection;
couplages_appliques?: string[];
}
export interface ResultatReajuste {
axes: Selection;
total: number;
status: Statut;
message: string;
couplages_appliques: string[];
debug_reajust: string[] | null;
}
export declare function reajusteBudget(apres_couplages: EntreeReajuste, budget_total: number, options: Option[], postes: Poste[] | null | undefined, inputs: Autodiag, contexte_derived: Contexte, reglages: Reglages, debug?: boolean): ResultatReajuste;
+299
View File
@@ -0,0 +1,299 @@
// reajuste.ts — Étape 5 : réajustement budget global
// Spec: V2-algo-spec.md §Étape 5 — origine calculs/src/lib/autonomie/reajuste.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()).
// Comportement d'origine conservé : la fonction modifie EN PLACE les axes
// reçus (qui sont déjà la copie produite par appliqueCouplages) et les renvoie.
//
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
// (Intl). Texte de présentation, non assert par les tests ; à sortir du noyau
// en patch si l'octet-pour-octet inter-environnements devient un besoin.
import { cout_typologie, dimension_optimale } from './dimension.js';
import { parseJsonArr } from './types.js';
export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) {
const config = reglages.config;
const coefficients = reglages.coefficients;
const tolerances = config.tolerances || {};
const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100;
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100;
const axes = apres_couplages.axes;
const axes_list = ['eau', 'energie', 'alim'];
function getTousPostes() {
return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
}
function calcTotal() {
return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0);
}
function recalcSousTotal(axe) {
const a = axes[axe];
if (a) {
a.sous_total_eur_ht = a.postes.reduce((s, p) => s + (p.cout_eur_ht || 0), 0);
}
}
let total = calcTotal();
let status = 'OK';
let message = '';
const log = debug ? [] : null;
// CAS A : overrun
if (total > budget_total * overrun_seuil) {
if (log)
log.push('CAS A : total ' + total + ' > seuil ' + Math.round(budget_total * overrun_seuil));
let iterations = 0;
while (total > budget_total * overrun_seuil && iterations < 80) {
iterations++;
// Correctif V3-1 (recalibrage) : downgrade "au plus pres du budget" au
// lieu du moins cher. Historiquement, le poste candidat sautait vers son
// alternative LA MOINS CHERE (ex : poele de masse -> rocket stove,
// -23 000 EUR la ou quelques centaines suffisaient a repasser sous le
// seuil). Desormais : parmi TOUS les downgrades possibles on choisit
// celui dont le total resultant est le plus proche du budget cible —
// evite a la fois le surcout et le sous-equipement massif.
const tous = getTousPostes().filter((p) => !p.force_par_couplage && !p.manuel);
const candidats = [];
for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options);
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
const alts_scored = alternatives
.map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
const cout = cout_typologie(alt, dim, inputs);
return { alt, dim, cout };
})
.filter((a) => a.cout < (poste_candidat.cout_eur_ht || 0));
for (const a of alts_scored) {
candidats.push({
poste: poste_candidat,
alt: a,
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout),
});
}
}
let choix = null;
if (candidats.length > 0) {
// total resultant le plus proche du budget (tiebreak : pire score d'abord)
candidats.sort((a, b) => Math.abs(total - a.diff - budget_total) - Math.abs(total - b.diff - budget_total) ||
(a.poste.score_final || 0) - (b.poste.score_final || 0));
choix = candidats[0];
}
let downgraded = false;
if (choix) {
const poste_candidat = choix.poste;
const meilleur = choix.alt;
const diff = choix.diff;
const axe_poste = poste_candidat._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
if (idx >= 0) {
const p = ax.postes[idx];
ax.postes[idx] = Object.assign({}, p, {
typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension,
cout_eur_ht: Math.round(meilleur.cout),
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
_downgraded: true,
});
recalcSousTotal(axe_poste);
total -= diff;
downgraded = true;
if (log)
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR');
}
}
if (!downgraded) {
const optionnels = getTousPostes().filter((p) => !p.obligatoire && !p.force_par_couplage && !p.manuel);
optionnels.sort((a, b) => (a.score_final || 0) - (b.score_final || 0));
if (optionnels.length > 0) {
const pire = optionnels[0];
const axe_poste = pire._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie);
if (idx >= 0) {
total -= ax.postes[idx].cout_eur_ht || 0;
ax.postes.splice(idx, 1);
recalcSousTotal(axe_poste);
downgraded = true;
if (log)
log.push('Retrait optionnel ' + pire.typologie_choisie);
}
}
if (!downgraded)
break;
}
}
if (total > budget_total * overrun_seuil) {
status = 'BUDGET_INSUFFISANT';
message =
'Budget insuffisant pour équiper tous les postes obligatoires. Estimation minimale : ' +
Math.round(total).toLocaleString('fr-FR') +
' EUR. Votre budget : ' +
Math.round(budget_total).toLocaleString('fr-FR') +
' EUR.';
if (log)
log.push('BUDGET_INSUFFISANT — toutes alternatives épuisées');
}
}
// CAS B : underspend
else if (total < budget_total * underspend_seuil) {
if (log)
log.push('CAS B : total ' + total + ' < seuil ' + Math.round(budget_total * underspend_seuil));
// Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) :
// ne jamais y réinjecter de postes optionnels. Marqueur posé par
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
const axes_desactives = new Set(config._axes_desactives || []);
// Identifier les postes BDD non encore sélectionnés (optionnels manquants)
const codes_selectionnes = new Set(getTousPostes().map((p) => p.typologie_choisie));
// Construire mapping poste_code → axe via postes BDD
const posteIdToAxe = {};
if (postes) {
for (const p of postes) {
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
posteIdToAxe[p.Id] = axe_norm;
}
}
let iterations = 0;
while (total < budget_total * underspend_seuil && iterations < 80) {
iterations++;
let upgraded = false;
// Stratégie 1 : upgrader des typologies existantes
const tous = getTousPostes().filter((p) => !p.manuel);
tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0));
for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options);
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
const alts_scored = alternatives
.map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
const cout = cout_typologie(alt, dim, inputs);
// Estimer le coût des couplages requis non encore sélectionnés
const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie));
const alias = (config && config.couplages_alias) || {};
const requis_list = parseJsonArr(alt.couplages_requis);
let cout_couplages_requis = 0;
for (const r of requis_list) {
const r_resolved = alias[r] || r;
if (!codes_sel.has(r) && !codes_sel.has(r_resolved)) {
const typo_req = options.find((o) => o.code === r_resolved || o.code === r);
if (typo_req) {
const dim_req = dimension_optimale(typo_req, inputs, contexte_derived, coefficients);
cout_couplages_requis += cout_typologie(typo_req, dim_req, inputs);
}
}
}
return { alt, dim, cout, cout_couplages_requis };
// Ne conserver que les upgrades qui ne dépassent pas le budget global
// en incluant le coût estimé des couplages requis non encore satisfaits
})
.filter((a) => {
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0);
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil;
});
if (alts_scored.length > 0) {
alts_scored.sort((a, b) => a.cout - b.cout);
const meilleur = alts_scored[0];
const diff = Math.round(meilleur.cout) - (poste_candidat.cout_eur_ht || 0);
const axe_poste = poste_candidat._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
if (idx >= 0) {
const p = ax.postes[idx];
ax.postes[idx] = Object.assign({}, p, {
typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension,
cout_eur_ht: Math.round(meilleur.cout),
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
_upgraded: true,
});
recalcSousTotal(axe_poste);
total += diff;
upgraded = true;
if (log)
log.push('Upgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' +' + diff + 'EUR');
break;
}
}
}
// Stratégie 2 : ajouter des postes optionnels non encore sélectionnés
if (!upgraded) {
const postes_manquants = options.filter((o) => {
if (o.actif === false || codes_selectionnes.has(o.code))
return false;
// Ne pas ajouter de poste appartenant à un axe désactivé (V3)
if (axes_desactives.size > 0 && posteIdToAxe[o.poste_id] && axes_desactives.has(posteIdToAxe[o.poste_id]))
return false;
return true;
});
// Trier par rapport score/cout et taille raisonnable
const candidats_add = postes_manquants
.map((o) => {
const dim = dimension_optimale(o, inputs, contexte_derived, coefficients);
const cout = cout_typologie(o, dim, inputs);
return { opt: o, dim, cout };
})
.filter((a) => a.cout > 0 && total + a.cout <= budget_total * overrun_seuil);
candidats_add.sort((a, b) => a.cout - b.cout);
if (candidats_add.length > 0) {
const best = candidats_add[candidats_add.length - 1]; // le plus cher qui rentre
const axe_target = posteIdToAxe[best.opt.poste_id] || 'energie';
const ax = axes[axe_target];
if (ax) {
const valeur_dim = best.dim ? best.dim.valeur : best.opt.quantite_default || 1;
const unite_dim = best.dim ? best.dim.unite : best.opt.unite || '';
ax.postes.push({
poste_id: best.opt.poste_id,
poste_nom: best.opt.nom,
obligatoire: false,
budget_alloue_poste: Math.round(best.cout),
typologie_choisie: best.opt.code,
nom_affichage: best.opt.nom,
description_pedago: best.opt.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: Math.round(best.cout),
score_adequation: 0.6,
score_final: 0,
alternatives: [],
notes_techniques: best.opt.notes_techniques || '',
flag_diy: best.opt.flag_diy || false,
complexite_pose: best.opt.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
_added_cas_b: true,
});
ax.sous_total_eur_ht += Math.round(best.cout);
total += Math.round(best.cout);
codes_selectionnes.add(best.opt.code);
upgraded = true;
if (log)
log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR');
}
}
}
if (!upgraded) {
status = 'AJOUTER_OPTIONNEL';
message = 'Budget non consommé (' + Math.round(budget_total - total).toLocaleString('fr-FR') + ' EUR disponibles). Postes optionnels suggérés non inclus.';
if (log)
log.push('AJOUTER_OPTIONNEL — budget non consommable');
break;
}
}
}
total = calcTotal();
return {
axes,
total: Math.round(total),
status,
message,
couplages_appliques: apres_couplages.couplages_appliques ?? [],
debug_reajust: log,
};
}
function _posteIdNum(poste_result, options) {
const typo = options.find((o) => o.code === poste_result.typologie_choisie);
return typo ? typo.poste_id : -1;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js';
/**
* score_pluviometrie — pertinent pour stockage eau, récupération pluie
* Volume récupéré annuel vs capacité de la cuve (cible : 2 mois de réserve)
*/
export declare function score_pluviometrie(typo: Option, inputs: Autodiag, contexte_derived: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number;
/**
* score_climat_chauffage — pertinent pour postes chauffage, ECS
* Plus le DJU est élevé, plus une typologie puissante est pertinente
*/
export declare function score_climat_chauffage(typo: Option, _inputs: Autodiag, contexte_derived: Contexte): number;
/**
* score_climat_meridional — bonus solutions sécheresse/canicule zone MED
*/
export declare function score_climat_meridional(typo: Option, _inputs: Autodiag, contexte_derived: Contexte): number;
/**
* score_taille_foyer — pertinent pour postes dimensionnables au nb personnes
*/
export declare function score_taille_foyer(typo: Option, inputs: Autodiag): number;
/**
* score_surface_toit — pertinent pour PV, récupération eau pluie
*/
export declare function score_surface_toit(typo: Option, inputs: Autodiag, _contexte: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number;
/**
* score_orientation_toit — PV uniquement
* Sud = 1.0, dégradation linéaire, nord = forte pénalité
*/
export declare function score_orientation_toit(_typo: Option, inputs: Autodiag, _contexte: Contexte, poste: Poste | undefined, _sel: string[], reglages: Reglages): number;
/**
* score_surface_parcelle — pertinent pour alim, phyto-épuration, irrigation
*/
export declare function score_surface_parcelle(typo: Option, inputs: Autodiag): number;
/**
* score_temps_dispo — modulation par disponibilité utilisateur
*/
export declare function score_temps_dispo(typo: Option, inputs: Autodiag): number;
/**
* score_metres_haie — pertinent pour haies
*/
export declare function score_metres_haie(typo: Option, inputs: Autodiag): number;
/**
* score_couplage_disponible — bonus synergies, calculé post-sélection initiale (étape 4)
* @param typologies_selectionnees — codes déjà sélectionnés
*/
export declare function score_couplage_disponible(typo: Option, _inputs: Autodiag, _contexte: Contexte, _poste: Poste, typologies_selectionnees?: string[]): number;
/** Facteur d'orientation PV depuis la table de coefficients ; undefined si orientation inconnue. */
export declare function facteurOrientation(reglages: Reglages, orientation: string | undefined): number | undefined;
+170
View File
@@ -0,0 +1,170 @@
// scoreurs.ts — 10 scoreurs réutilisables [0..1]
// Spec: V2-algo-spec-FONCTIONS.md §1.2 — origine calculs/src/lib/autonomie/scoreurs.js.
// Correction LOT P1.0 : les coefficients (rendement de captage, facteurs
// d'orientation) arrivent par `reglages` au lieu d'une constante de module.
//
// Note (bug latent d'origine, conservé pour la non-régression, à corriger en
// patch) : score_pluviometrie, score_climat_chauffage et score_metres_haie
// lisent `typo.parametre_dim` sans le parser ; quand il arrive en JSON
// sérialisé (cas des données publiées), ils retombent sur leurs défauts.
import { clamp01 } from './calibrage.js';
import { parseJsonArr } from './types.js';
function paramBrut(typo) {
// Reproduit `typo.parametre_dim?.default` de l'origine : un objet est lu,
// une chaîne JSON ne l'est PAS (ses propriétés sont undefined).
const p = typo.parametre_dim;
return p && typeof p === 'object' ? p : null;
}
/**
* score_pluviometrie — pertinent pour stockage eau, récupération pluie
* Volume récupéré annuel vs capacité de la cuve (cible : 2 mois de réserve)
*/
export function score_pluviometrie(typo, inputs, contexte_derived, _poste, _sel, reglages) {
const surface = inputs.surface_toit_m2 ?? 0;
const pluvio = contexte_derived.pluviometrie_mm ?? 800;
const volume_recup_annuel = surface * (pluvio / 1000) * reglages.coefficients.captage_rendement.valeur;
const cible_2mois = volume_recup_annuel / 6;
const dim_default = paramBrut(typo)?.default ?? 8;
if (cible_2mois <= 0)
return 0.3;
const ratio = dim_default / cible_2mois;
return clamp01(1 - 0.5 * Math.abs(1 - ratio));
}
/**
* score_climat_chauffage — pertinent pour postes chauffage, ECS
* Plus le DJU est élevé, plus une typologie puissante est pertinente
*/
export function score_climat_chauffage(typo, _inputs, contexte_derived) {
const dju = contexte_derived.dju ?? 2200;
const puissance = paramBrut(typo)?.default ?? 5;
const puissance_cible = dju / 500; // heuristique ~5 kW pour H1
if (puissance_cible <= 0)
return 0.5;
return clamp01(1 - (0.4 * Math.abs(puissance - puissance_cible)) / puissance_cible);
}
/**
* score_climat_meridional — bonus solutions sécheresse/canicule zone MED
*/
export function score_climat_meridional(typo, _inputs, contexte_derived) {
const is_med = contexte_derived.zone_biogeo === 'MED';
if (!is_med)
return 0.5; // neutre hors MED
const tags = typo.tags ?? [];
const med_tags = ['frais_passif', 'sec', 'mediterraneen'];
return med_tags.some((t) => tags.includes(t)) ? 1.0 : 0.5;
}
/**
* score_taille_foyer — pertinent pour postes dimensionnables au nb personnes
*/
export function score_taille_foyer(typo, inputs) {
const param = paramBrut(typo);
if (!param || param.strategie !== 'par_personne')
return 0.5;
const base = param.base ?? 0;
const coef = param.coef ?? 1;
const min = param.min ?? 0;
const max = param.max ?? 100;
const dim_calc = base + (inputs.nb_personnes ?? 4) * coef;
if (max === min)
return 0.5;
const ratio_in_range = clamp01((dim_calc - min) / (max - min));
return 0.4 + 0.6 * ratio_in_range;
}
/**
* score_surface_toit — pertinent pour PV, récupération eau pluie
*/
export function score_surface_toit(typo, inputs, _contexte, _poste, _sel, reglages) {
const param = paramBrut(typo);
if (!param || param.strategie !== 'par_surface_toit')
return 0.5;
const orient_factor = facteurOrientation(reglages, inputs.orientation_toit) ?? 0.8;
const surface_utilisable = (inputs.surface_toit_m2 ?? 0) * orient_factor;
const coef = param.coef ?? 0.1;
const min = param.min ?? 0;
const max = param.max ?? 100;
const taille_optimale = surface_utilisable * coef;
if (max === min)
return 0.5;
const ratio_in_range = clamp01((taille_optimale - min) / (max - min));
return 0.3 + 0.7 * ratio_in_range;
}
/**
* score_orientation_toit — PV uniquement
* Sud = 1.0, dégradation linéaire, nord = forte pénalité
*/
export function score_orientation_toit(_typo, inputs, _contexte, poste, _sel, reglages) {
// On applique ce score dès qu'on est sur un poste PV (energie_pv)
if (!poste || !poste.code?.includes('pv'))
return 0.5;
return facteurOrientation(reglages, inputs.orientation_toit) ?? 0.5;
}
/**
* score_surface_parcelle — pertinent pour alim, phyto-épuration, irrigation
*/
export function score_surface_parcelle(typo, inputs) {
const surface_utile = Math.max(0, (inputs.surface_parcelle_m2 ?? 0) - 100);
const surface_min = typo.surface_min_m2 ?? 0;
if (surface_utile < surface_min)
return 0.0; // hard exclude
const surface_optimale = typo.surface_optimale_m2 ?? 500;
const ratio = surface_utile / surface_optimale;
return clamp01(0.4 + 0.6 * Math.min(ratio, 1));
}
/**
* score_temps_dispo — modulation par disponibilité utilisateur
*/
export function score_temps_dispo(typo, inputs) {
const td = inputs.temps_dispo ?? 2;
const pose = typo.complexite_pose ?? 2;
const diy = typo.flag_diy ?? false;
if (td === 1) {
if (pose === 1 && diy)
return 0.8;
if (pose === 3)
return 0.4;
return 0.6;
}
if (td === 3) {
if (diy)
return 1.0;
if (pose === 3)
return 0.7;
return 0.7;
}
return 0.7; // td === 2
}
/**
* score_metres_haie — pertinent pour haies
*/
export function score_metres_haie(typo, inputs) {
const metres = inputs.metres_haie_possibles ?? 0;
// Origine : `if (!param) …` sur le champ brut — une chaîne JSON (truthy)
// passe donc dans la branche « max ?? 100 ». Conservé tel quel.
const brut = typo.parametre_dim;
if (!brut)
return metres > 10 ? 0.6 : 0.3;
const max = paramBrut(typo)?.max ?? 100;
return clamp01(0.3 + 0.7 * Math.min(metres / max, 1));
}
/**
* score_couplage_disponible — bonus synergies, calculé post-sélection initiale (étape 4)
* @param typologies_selectionnees — codes déjà sélectionnés
*/
export function score_couplage_disponible(typo, _inputs, _contexte, _poste, typologies_selectionnees = []) {
const requis = parseJsonArr(typo.couplages_requis);
const exclus = parseJsonArr(typo.couplages_exclus);
const has_requis = requis.some((c) => typologies_selectionnees.includes(c));
const has_exclus = exclus.some((c) => typologies_selectionnees.includes(c));
if (has_exclus)
return 0.0;
if (has_requis)
return 1.0;
return 0.5;
}
/** Facteur d'orientation PV depuis la table de coefficients ; undefined si orientation inconnue. */
export function facteurOrientation(reglages, orientation) {
if (!orientation)
return undefined;
const table = reglages.coefficients.orientation_pv.valeur;
return table[orientation];
}
+8
View File
@@ -0,0 +1,8 @@
import type { Autodiag, Axe, AxeResultat, Contexte, Option, Poste, Reglages } from './types.js';
/**
* Sélectionne les meilleures typologies pour un axe donné.
*
* @param budget_axe — EUR alloué à cet axe
* @param axe — "eau" | "energie" | "alim"
*/
export declare function selectionnePoste(budget_axe: number, inputs: Autodiag, contexte_derived: Contexte, options: Option[], postes: Poste[], axe: Axe, reglages: Reglages, debug?: boolean): AxeResultat;
+148
View File
@@ -0,0 +1,148 @@
// selectionne-poste.ts — Étape 3 : sélection des typologies par poste
// Spec: V2-algo-spec.md §Étape 3 + V2-algo-spec-FONCTIONS.md §3
// Origine calculs/src/lib/autonomie/selectionne-poste.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
// console.warn de debug est versé dans `debug_log` du résultat.
import { adequation_contexte } from './adequation.js';
import { dimension_optimale, cout_typologie } from './dimension.js';
import { parseJsonArr } from './types.js';
/**
* Sélectionne les meilleures typologies pour un axe donné.
*
* @param budget_axe — EUR alloué à cet axe
* @param axe — "eau" | "energie" | "alim"
*/
export function selectionnePoste(budget_axe, inputs, contexte_derived, options, postes, axe, reglages, debug = false) {
const config = reglages.config;
const bonus_malus = config.bonus_malus || {};
const postes_poids_cfg = config.postes_poids || {};
const tolerances = config.tolerances || {};
const tolerance_pct = (tolerances.tolerance_par_poste_pct || 20) / 100;
const debug_log = debug ? [] : undefined;
// Normaliser le code axe pour matcher la BDD ("alimentation" dans postes BDD)
const axe_bdd = axe === 'alim' ? 'alimentation' : axe;
// Postes de l'axe, triés par ordre d'affichage
const postes_axe = postes.filter((p) => p.axe === axe_bdd && p.actif !== false).sort((a, b) => (a.ordre || 0) - (b.ordre || 0));
if (postes_axe.length === 0) {
return { postes: [], sous_total_eur_ht: 0, budget_alloue: budget_axe, poids: 0 };
}
// Enrichir les postes avec poids depuis config (fallback 50)
const postes_enrichis = postes_axe.map((p) => ({
...p,
poids_effectif: postes_poids_cfg[p.code] && postes_poids_cfg[p.code].poids != null ? postes_poids_cfg[p.code].poids : 50,
}));
// Poids total pour répartition budget
const poids_total = postes_enrichis.reduce((s, p) => s + p.poids_effectif, 0);
const resultats_postes = [];
for (const poste of postes_enrichis) {
// Budget alloué à ce poste (proportionnel au poids)
const budget_poste = poids_total > 0 ? (budget_axe * poste.poids_effectif) / poids_total : budget_axe / postes_enrichis.length;
// Si alim avec budget 0 (parcelle trop petite) → skip
if (budget_axe === 0)
continue;
// Typologies disponibles pour ce poste
const typos_poste = options.filter((o) => o.poste_id === poste.Id && o.actif !== false);
if (typos_poste.length === 0)
continue;
// Filtrer par accessibilité contexte (§3.4)
let typos_filtrees = typos_poste.filter((typo) => {
// Exclure complexité 3 si temps_dispo = 1 (sauf si c'est la seule option)
if (inputs.temps_dispo === 1 && typo.complexite_pose === 3)
return false;
// Exclure PV sur toit nord
if (poste.code === 'energie_pv' && inputs.orientation_toit === 'nord')
return false;
return true;
});
// Garde-fou : si trop restrictif, revenir à toutes
if (typos_filtrees.length === 0)
typos_filtrees = typos_poste;
// Scorer chaque typologie (§3.5)
const scored = typos_filtrees.map((typo) => {
const dim = dimension_optimale(typo, inputs, contexte_derived, reglages.coefficients);
const cout = cout_typologie(typo, dim, inputs);
const adq_result = debug
? adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], true)
: adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], false);
const score_adequation = debug && adq_result && typeof adq_result === 'object' && 'score' in adq_result
? adq_result.score
: typeof adq_result === 'number'
? adq_result
: 0.5;
// Boost DIY temps=3
let score_utilite = (poste.poids_effectif || 50) * score_adequation;
if (inputs.temps_dispo === 3 && typo.flag_diy) {
score_utilite *= bonus_malus.boost_diy_temps3 || 1.3;
}
const score_final = cout > 0 ? score_utilite / cout : 0;
return {
typo,
dim,
cout: Math.round(cout),
score_adequation,
score_utilite,
score_final,
debug_adq: debug && typeof adq_result === 'object' ? adq_result : undefined,
};
});
// Trier par score_final décroissant
scored.sort((a, b) => b.score_final - a.score_final);
// §3.6 : sélectionner le meilleur dont cout <= budget_poste × (1 + tolerance)
// Ne PAS prendre un poste si le coût dépasse largement le budget (anti-overrun)
const seuil_strict = budget_poste * (1 + tolerance_pct);
// Seuil assoupli : si aucun poste ne rentre, prendre le moins cher
let choix = scored.find((s) => s.cout <= seuil_strict);
if (!choix && scored.length > 0) {
// Pas de typo dans le budget : prendre la moins chère
const par_cout = [...scored].sort((a, b) => a.cout - b.cout);
choix = par_cout[0];
if (debug_log)
debug_log.push('[selectionnePoste] Aucune typo dans budget pour poste ' + poste.code + ' (budget=' + Math.round(budget_poste) + ', min cout=' + choix.cout + ')');
}
if (!choix)
continue;
// Alternatives (2-4 autres options scorées)
const alternatives = scored
.filter((s) => s.typo.code !== choix.typo.code)
.slice(0, 3)
.map((s) => ({
code: s.typo.code,
nom_affichage: s.typo.nom,
cout_eur_ht: s.cout,
score_final: +s.score_final.toFixed(4),
dimension: s.dim ? { valeur: s.dim.valeur, unite: s.dim.unite } : null,
}));
const couplages_requis = parseJsonArr(choix.typo.couplages_requis);
const couplages_exclus = parseJsonArr(choix.typo.couplages_exclus);
resultats_postes.push({
poste_id: poste.code,
poste_nom: poste.poste,
obligatoire: poste.obligatoire || false,
budget_alloue_poste: Math.round(budget_poste),
typologie_choisie: choix.typo.code,
nom_affichage: choix.typo.nom,
description_pedago: choix.typo.description_pedago || '',
dimension: choix.dim || { valeur: choix.typo.quantite_default || 1, unite: choix.typo.unite || '' },
cout_eur_ht: choix.cout,
score_adequation: +choix.score_adequation.toFixed(3),
score_final: +choix.score_final.toFixed(4),
alternatives,
notes_techniques: choix.typo.notes_techniques || '',
flag_diy: choix.typo.flag_diy || false,
complexite_pose: choix.typo.complexite_pose || 2,
couplages_requis,
couplages_exclus,
manuel: false,
});
}
const sous_total = resultats_postes.reduce((s, p) => s + p.cout_eur_ht, 0);
const resultat = {
postes: resultats_postes,
sous_total_eur_ht: sous_total,
budget_alloue: budget_axe,
poids: 0, // rempli par l'orchestrateur
};
if (debug_log && debug_log.length)
resultat.debug_log = debug_log;
return resultat;
}
+180
View File
@@ -0,0 +1,180 @@
import type { TableCoefficients, Orientation } from '../../commun/coefficients.js';
import type { Contexte, LigneClimat } from '../../commun/contexte.js';
export type { Contexte, LigneClimat, Orientation };
export type Axe = 'eau' | 'energie' | 'alim';
export declare const AXES: readonly Axe[];
/** Réponses de l'autodiag utiles aux moteurs (toutes facultatives : le noyau applique ses défauts). */
export interface Autodiag {
code_postal?: string | number;
nb_personnes?: number;
surface_parcelle_m2?: number;
surface_toit_m2?: number;
orientation_toit?: Orientation | string;
pente_toit_deg?: number;
metres_haie_possibles?: number;
/** 1 = faire poser, 2 = participation ponctuelle, 3 = auto-pose. */
temps_dispo?: 1 | 2 | 3 | number;
}
/** Entrées de `selectionner()` : autodiag + budget total. */
export interface InputsSimulation extends Autodiag {
budget_eur: number;
}
export interface Poste {
Id: number;
axe: string;
code: string;
poste: string;
ordre?: number | null;
obligatoire?: boolean | null;
actif?: boolean | null;
[autre: string]: unknown;
}
export interface ParamDim {
nom?: string;
strategie?: string;
base?: number;
coef?: number;
min?: number;
max?: number;
step?: number;
default?: number;
unite?: string;
reserve_construit?: number;
}
export interface Option {
Id: number;
poste_id: number;
code: string;
nom: string;
typologie?: string | null;
actif?: boolean | null;
prix_mat_min?: number | null;
prix_mat_max?: number | null;
prix_pro_min?: number | null;
prix_pro_max?: number | null;
prix_par_unite?: number | null;
prix_fixe?: number | null;
unite?: string | null;
quantite_default?: number | null;
/** JSON sérialisé (NocoDB) ou objet déjà parsé. */
parametre_dim?: string | ParamDim | null;
couplages_requis?: string | string[] | null;
couplages_exclus?: string | string[] | null;
flag_diy?: boolean | null;
complexite_pose?: number | null;
accessible_fr?: boolean | null;
surface_min_m2?: number | null;
surface_optimale_m2?: number | null;
tags?: string[] | null;
description_pedago?: string | null;
notes_techniques?: string | null;
[autre: string]: unknown;
}
export interface AlgoConfig {
poids_axes_base?: Record<Axe, number>;
modulateurs?: Record<string, unknown>;
adequation_weights?: Record<string, Record<string, number>>;
tolerances?: {
budget_overrun_pct?: number;
budget_underspend_pct?: number;
tolerance_par_poste_pct?: number;
};
bonus_malus?: {
boost_diy_temps3?: number;
malus_pose3_temps1?: number;
malus_non_accessible_fr?: number;
};
couplages_alias?: Record<string, string>;
postes_poids?: Record<string, {
poids?: number;
}>;
/** Marqueur posé par axes-actifs.ts (configPourAxes). */
_axes_desactives?: Axe[];
[autre: string]: unknown;
}
/** Ce qu'un calcul reçoit en plus des données : heuristiques + coefficients sourcés. */
export interface Reglages {
config: AlgoConfig;
coefficients: TableCoefficients;
}
export interface Dimension {
valeur: number;
unite: string;
strategie?: string;
}
export interface Alternative {
code: string;
nom_affichage: string;
cout_eur_ht: number;
score_final: number;
dimension: {
valeur: number;
unite: string;
} | null;
}
export interface PosteResultat {
poste_id: string | number;
poste_nom: string;
obligatoire: boolean;
budget_alloue_poste: number;
typologie_choisie: string;
nom_affichage: string;
description_pedago: string;
dimension: Dimension;
cout_eur_ht: number;
score_adequation: number;
score_final: number;
alternatives: Alternative[];
notes_techniques: string;
flag_diy: boolean;
complexite_pose: number;
couplages_requis: string[];
couplages_exclus: string[];
manuel: boolean;
force_par_couplage?: boolean;
_axe?: Axe;
_downgraded?: boolean;
_upgraded?: boolean;
_added_cas_b?: boolean;
_conflit_avec?: string;
}
export interface AxeResultat {
postes: PosteResultat[];
sous_total_eur_ht: number;
budget_alloue: number;
poids: number;
/** Journal de débogage (seulement si debug). */
debug_log?: string[];
}
/** Sélection sur les trois axes (un axe peut manquer quand un moteur d'axe tourne seul). */
export type Selection = Partial<Record<Axe, AxeResultat>>;
export type Statut = 'OK' | 'BUDGET_INSUFFISANT' | 'AJOUTER_OPTIONNEL';
export interface PoidsAxes {
eau: number;
energie: number;
alim: number;
}
export interface EtapeDebug {
etape: number;
label: string;
[autre: string]: unknown;
}
export interface SortieSelection {
axes: Selection;
total_eur_ht: number;
budget_cible: number;
ecart_budget_pct: number;
status: Statut;
message_status?: string;
couplages: string[];
contexte: Contexte;
/** Avertissements du contexte (code postal inconnu → défauts). */
avertissements: string[];
debug?: {
steps: EtapeDebug[];
};
}
/** Parse un champ JSON sérialisé par NocoDB (tableau) ; tolère un tableau déjà parsé. */
export declare function parseJsonArr(val: unknown): string[];
/** Parse parametre_dim (string JSON ou objet). */
export declare function parseParam(raw: unknown): ParamDim | null;
+35
View File
@@ -0,0 +1,35 @@
// types.ts — types du noyau autonomie (V2 algo, calculs.trans-former.fr).
// Les formes de données (Poste, Option, LigneClimat) reflètent les JSON publiés
// par calculs ; les schémas de validation sont dans commun/schemas.ts.
export const AXES = ['eau', 'energie', 'alim'];
/** Parse un champ JSON sérialisé par NocoDB (tableau) ; tolère un tableau déjà parsé. */
export function parseJsonArr(val) {
if (!val || val === 'null')
return [];
if (Array.isArray(val))
return val;
if (typeof val !== 'string')
return [];
try {
const p = JSON.parse(val);
return Array.isArray(p) ? p : [];
}
catch {
return [];
}
}
/** Parse parametre_dim (string JSON ou objet). */
export function parseParam(raw) {
if (!raw)
return null;
if (typeof raw === 'object')
return raw;
if (typeof raw !== 'string')
return null;
try {
return JSON.parse(raw);
}
catch {
return null;
}
}