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
144 lines
6.6 KiB
TypeScript
144 lines
6.6 KiB
TypeScript
// axe.ts — fabrique des moteurs d'axe `autonomie.eau`, `autonomie.energie`,
|
|
// `autonomie.alimentation` (LOT P1.0, découpage par axe).
|
|
//
|
|
// Un moteur d'axe = selectionnePoste(axe) + couplages de l'axe + réajustement
|
|
// au budget de l'axe + taux de couverture de l'axe. Il réutilise la queue du
|
|
// pipeline d'origine (`finaliseSelection`) sur une sélection où les deux
|
|
// autres axes sont vides et désactivés (`configPourAxes`) : par construction,
|
|
// il rend exactement ce que `selectionner()` rend en mode « un seul axe
|
|
// actif » avec ce budget — c'est le test de non-régression du découpage.
|
|
|
|
import type { Moteur, Resultat } from '../commun/contrat.js'
|
|
import { Trace } from '../commun/contrat.js'
|
|
import { COEFFICIENTS, type TableCoefficients } from '../commun/coefficients.js'
|
|
import type { Contexte } from '../commun/contexte.js'
|
|
import { VERSION } from '../version.js'
|
|
import { finaliseSelection } from './noyau/algo.js'
|
|
import { configPourAxes } from './noyau/axes-actifs.js'
|
|
import { selectionnePoste } from './noyau/selectionne-poste.js'
|
|
import type { AlgoConfig, Autodiag, Axe, AxeResultat, Option, Poste, PosteResultat, Reglages, Selection, Statut } from './noyau/types.js'
|
|
import { AXES } from './noyau/types.js'
|
|
import { aCaptage, batterieSelectionnee, kwcSelectionnes, mlHaiesSelectionnes, tauxAlim, tauxEau, tauxEnergie, type DetailAlim, type DetailEau, type DetailEnergie } from './taux.js'
|
|
|
|
export interface EntreesAxe {
|
|
autodiag: Autodiag
|
|
/** Budget alloué à cet axe (EUR HT). */
|
|
budget_axe: number
|
|
/** Contexte climatique (moteur `commun.contexte`). */
|
|
contexte: Contexte
|
|
}
|
|
|
|
export interface DonneesAutonomie {
|
|
postes: Poste[]
|
|
options: Option[]
|
|
algo_config: AlgoConfig
|
|
/** Table de coefficients ; par défaut la table harmonisée du paquet. */
|
|
coefficients?: TableCoefficients
|
|
}
|
|
|
|
export interface SortiesAxe {
|
|
axe: Axe
|
|
postes: PosteResultat[]
|
|
/** Codes des typologies retenues, dans l'ordre des postes. */
|
|
typologies: string[]
|
|
sous_total_eur_ht: number
|
|
budget_alloue: number
|
|
ecart_budget_pct: number
|
|
status: Statut
|
|
message_status?: string
|
|
/** Journal des couplages (FORCE / MANQUANT / WARN / CONFLIT). */
|
|
couplages: string[]
|
|
/** Taux de couverture de l'axe en %, null si non calculable. */
|
|
taux_couverture: number | null
|
|
taux_detail: DetailEau | DetailEnergie | DetailAlim | null
|
|
}
|
|
|
|
/** Enregistre les clés de coefficients lues pendant un calcul (Proxy en lecture seule). */
|
|
export function enregistreLectures(table: TableCoefficients): { table: TableCoefficients; lues: Set<keyof TableCoefficients> } {
|
|
const lues = new Set<keyof TableCoefficients>()
|
|
const proxy = new Proxy(table, {
|
|
get(cible, prop, recepteur) {
|
|
if (typeof prop === 'string' && prop in cible) lues.add(prop as keyof TableCoefficients)
|
|
return Reflect.get(cible, prop, recepteur)
|
|
},
|
|
})
|
|
return { table: proxy, lues }
|
|
}
|
|
|
|
function axeVide(): AxeResultat {
|
|
return { postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 }
|
|
}
|
|
|
|
/** Ajoute aux sources les coefficients lus et les prix des typologies retenues. */
|
|
export function traceSelection(trace: Trace, coefficients: TableCoefficients, lues: Set<keyof TableCoefficients>, postes: PosteResultat[], options: Option[], couplages: string[]): void {
|
|
for (const cle of lues) {
|
|
const c = coefficients[cle]
|
|
const v = c.valeur
|
|
trace.source(cle, typeof v === 'number' || typeof v === 'string' ? v : JSON.stringify(v), c.source, c.date)
|
|
}
|
|
for (const p of postes) {
|
|
const o = options.find((x) => x.code === p.typologie_choisie)
|
|
trace.source(`option.${p.typologie_choisie}`, p.cout_eur_ht, String(o?.source ?? 'options (calculs.trans-former.fr)'), String(o?.source_date ?? ''))
|
|
}
|
|
for (const ligne of couplages) {
|
|
if (ligne.startsWith('MANQUANT') || ligne.startsWith('WARN')) trace.avertit(`couplage : ${ligne}`)
|
|
}
|
|
}
|
|
|
|
export function calculeAxe(axe: Axe, entrees: EntreesAxe, donnees: DonneesAutonomie): Resultat<SortiesAxe> {
|
|
const trace = new Trace()
|
|
const { table: coefficients, lues } = enregistreLectures(donnees.coefficients ?? COEFFICIENTS)
|
|
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe])) as Record<Axe, boolean>
|
|
const reglages: Reglages = { config: configPourAxes(donnees.algo_config, actifs), coefficients }
|
|
const inputs = entrees.autodiag
|
|
const budget = Math.max(0, entrees.budget_axe || 0)
|
|
|
|
const selection: Selection = {}
|
|
for (const a of AXES) selection[a] = a === axe ? selectionnePoste(budget, inputs, entrees.contexte, donnees.options, donnees.postes, a, reglages) : axeVide()
|
|
selection[axe]!.poids = 100
|
|
|
|
const fin = finaliseSelection(selection, budget, donnees.options, donnees.postes, inputs, entrees.contexte, reglages)
|
|
const postes = fin.axes[axe]?.postes ?? []
|
|
const sous_total = fin.axes[axe]?.sous_total_eur_ht ?? 0
|
|
|
|
// Un poste forcé dans un autre axe par un couplage inter-axes serait perdu ici :
|
|
// aucun dans les données (inventaire §4), on le signale si ça arrive.
|
|
for (const a of AXES) {
|
|
if (a !== axe && (fin.axes[a]?.postes.length ?? 0) > 0) trace.avertit(`couplage inter-axes : ${fin.axes[a]!.postes.length} poste(s) forcé(s) dans l'axe ${a}, hors du périmètre du moteur ${axe}`)
|
|
}
|
|
|
|
let taux: { taux: number | null; detail: DetailEau | DetailEnergie | DetailAlim | null }
|
|
if (axe === 'eau') {
|
|
taux = tauxEau({ surface_toit_m2: inputs.surface_toit_m2, nb_personnes: inputs.nb_personnes, pluviometrie_mm: entrees.contexte.pluviometrie_mm, a_captage: aCaptage(postes) }, coefficients, trace)
|
|
} else if (axe === 'energie') {
|
|
taux = tauxEnergie(
|
|
{ kwc: kwcSelectionnes(postes), batterie_kwh: batterieSelectionnee(postes), orientation_toit: inputs.orientation_toit, nb_personnes: inputs.nb_personnes, contexte: entrees.contexte },
|
|
coefficients,
|
|
trace,
|
|
)
|
|
} else {
|
|
taux = tauxAlim({ ml: mlHaiesSelectionnes(postes), nb_personnes: inputs.nb_personnes }, coefficients, trace)
|
|
}
|
|
|
|
traceSelection(trace, donnees.coefficients ?? COEFFICIENTS, lues, postes, donnees.options, fin.couplages)
|
|
|
|
const sorties: SortiesAxe = {
|
|
axe,
|
|
postes,
|
|
typologies: postes.map((p) => p.typologie_choisie),
|
|
sous_total_eur_ht: sous_total,
|
|
budget_alloue: budget,
|
|
ecart_budget_pct: budget > 0 ? +(((sous_total - budget) / budget) * 100).toFixed(2) : 0,
|
|
status: fin.status,
|
|
message_status: fin.message_status,
|
|
couplages: fin.couplages,
|
|
taux_couverture: taux.taux,
|
|
taux_detail: taux.detail,
|
|
}
|
|
return trace.resultat(sorties, VERSION)
|
|
}
|
|
|
|
export function moteurAxe(axe: Axe, id: string): Moteur<EntreesAxe, DonneesAutonomie, SortiesAxe> {
|
|
return { id, calculer: (entrees, donnees) => calculeAxe(axe, entrees, donnees) }
|
|
}
|