// 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) } }