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
+83
View File
@@ -0,0 +1,83 @@
// ecarts-harmonisation.mjs — fait tourner les trois profils de référence avec
// le jeu de coefficients D'ORIGINE puis avec la table HARMONISÉE, et écrit les
// écarts de sélection ligne par ligne (markdown sur stdout ; JSON dans
// fixtures/ecarts-harmonisation-<date>.json avec --ecrire). Ce sont ces écarts
// que Jules relit avec docs/CONSTANTES.md (LOT P1.0 point 4, amendement du 28/09).
// Usage : node scripts/ecarts-harmonisation.mjs [--ecrire AAAA-MM-JJ]
import { readFileSync, writeFileSync } from 'node:fs'
import { COEFFICIENTS } from '../dist/commun/coefficients.js'
import { deriveContexte } from '../dist/commun/contexte.js'
import { eau, energie, alimentation, integrateur } from '../dist/autonomie/index.js'
const fx = new URL('../fixtures/', import.meta.url)
const lire = (f) => JSON.parse(readFileSync(new URL(f, fx), 'utf8'))
const options = lire('options.json')
const postes = lire('postes.json')
const france_climat = lire('france_climat.json')
const algo_config = lire('algo-config.json')
const profils = lire('profils-reference.json')
const origine = lire('coefficients-2026-05.json')
const AXES = ['eau', 'energie', 'alim']
const MOTEURS = { eau, energie, alim: alimentation }
export function calculeEcarts() {
const lignes = []
for (const profil of profils) {
for (const jeu of [
['origine', origine],
['harmonisee', COEFFICIENTS],
]) {
const [nom, coefficients] = jeu
const donnees = { postes, options, algo_config, france_climat, coefficients }
const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees).sorties
const contexte = deriveContexte(profil.inputs.code_postal, france_climat, coefficients).sorties
const par_axe = {}
for (const axe of AXES) {
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: g.budgets[axe], contexte }, donnees).sorties
par_axe[axe] = { typologies: r.typologies, sous_total: r.sous_total_eur_ht, status: r.status, taux: r.taux_couverture }
}
lignes.push({ profil: profil.nom, jeu: nom, global: { total: g.total_eur_ht, status: g.status, typologies: Object.fromEntries(AXES.map((a) => [a, g.axes[a].typologies])), taux: g.taux_couverture }, par_axe })
}
}
// Diff origine → harmonisée, par profil
const ecarts = []
for (const profil of profils) {
const o = lignes.find((l) => l.profil === profil.nom && l.jeu === 'origine')
const h = lignes.find((l) => l.profil === profil.nom && l.jeu === 'harmonisee')
for (const axe of AXES) {
const to = o.global.typologies[axe].join(' + ')
const th = h.global.typologies[axe].join(' + ')
if (to !== th) ecarts.push({ profil: profil.nom, mode: 'global', axe, origine: to, harmonisee: th })
const ao = o.par_axe[axe]
const ah = h.par_axe[axe]
if (ao.typologies.join('+') !== ah.typologies.join('+') || ao.sous_total !== ah.sous_total) {
ecarts.push({ profil: profil.nom, mode: 'par_axe', axe, origine: `${ao.typologies.join(' + ')} (${ao.sous_total} €)`, harmonisee: `${ah.typologies.join(' + ')} (${ah.sous_total} €)` })
}
if (ao.taux !== ah.taux) ecarts.push({ profil: profil.nom, mode: 'taux (par_axe)', axe, origine: `${ao.taux} %`, harmonisee: `${ah.taux} %` })
if (o.global.taux[axe] !== h.global.taux[axe]) ecarts.push({ profil: profil.nom, mode: 'taux (global)', axe, origine: `${o.global.taux[axe]} %`, harmonisee: `${h.global.taux[axe]} %` })
}
if (o.global.total !== h.global.total || o.global.status !== h.global.status) {
ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', origine: `${o.global.total} € (${o.global.status})`, harmonisee: `${h.global.total} € (${h.global.status})` })
}
}
return { lignes, ecarts }
}
const estCli = import.meta.url === new URL(`file:///${process.argv[1].replace(/\\/g, '/')}`).href || process.argv[1]?.endsWith('ecarts-harmonisation.mjs')
if (estCli) {
const { lignes, ecarts } = calculeEcarts()
console.log('| Profil | Mode | Axe | Origine (coefficients du code) | Harmonisée (table du paquet) |')
console.log('|---|---|---|---|---|')
for (const e of ecarts) console.log(`| ${e.profil} | ${e.mode} | ${e.axe} | ${e.origine} | ${e.harmonisee} |`)
if (ecarts.length === 0) console.log('| (aucun écart) | | | | |')
console.log()
console.log('Taux de couverture (eau / énergie / alim), par profil :')
for (const l of lignes) console.log(`- ${l.profil} — ${l.jeu} : ${AXES.map((a) => `${a} ${l.global.taux[a] ?? 'null'} %`).join(' · ')} — total ${l.global.total} € (${l.global.status})`)
const i = process.argv.indexOf('--ecrire')
if (i > 0) {
const date = process.argv[i + 1] ?? new Date().toISOString().slice(0, 10)
const cible = new URL(`ecarts-harmonisation-${date}.json`, fx)
writeFileSync(cible, JSON.stringify({ _commentaire: `Écarts de sélection et de taux entre le jeu de coefficients d'origine et la table harmonisée, sur les trois profils de référence, figés le ${date}. Un changement de coefficient ou de code qui modifie ces écarts casse test/decoupage.test.mjs : c'est voulu, on relit CONSTANTES.md avant de refiger.`, date, ecarts, lignes }, null, 2) + '\n', 'utf8')
console.log(`\nécrit : fixtures/ecarts-harmonisation-${date}.json`)
}
}
+12
View File
@@ -0,0 +1,12 @@
// Réécrit schemas/<table>.schema.json depuis dist/commun/schemas.js (source de
// vérité : src/commun/schemas.ts). Lancé par `npm run build` après tsc.
import { writeFileSync, mkdirSync } from 'node:fs'
import { SCHEMAS } from '../dist/commun/schemas.js'
const dossier = new URL('../schemas/', import.meta.url)
mkdirSync(dossier, { recursive: true })
for (const [table, schema] of Object.entries(SCHEMAS)) {
const json = { $schema: 'https://json-schema.org/draft/2020-12/schema', ...schema }
writeFileSync(new URL(`${table}.schema.json`, dossier), JSON.stringify(json, null, 2) + '\n', 'utf8')
}
console.log(`schemas/ : ${Object.keys(SCHEMAS).length} fichiers écrits`)
+3
View File
@@ -0,0 +1,3 @@
// Vide dist/ avant tsc (portable Windows / Linux, sans rm -rf).
import { rmSync } from 'node:fs'
rmSync(new URL('../dist/', import.meta.url), { recursive: true, force: true })
+76
View File
@@ -0,0 +1,76 @@
// pin-origine.mjs — génère fixtures/sorties-origine-<date>.json DEPUIS LE CODE
// D'ORIGINE (clone astro-pro, src/lib/autonomie), pas depuis ce paquet : c'est
// ce qui fait du test de non-régression une vraie épingle.
//
// Rejouable seulement avec un clone d'astro-pro à la révision d'origine
// (master c628cf9 au 2026-09-28) : ASTRO_PRO_DIR=~/dev/astro-pro par défaut.
// Usage : node scripts/pin-origine.mjs [AAAA-MM-JJ]
import { readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const ASTRO = process.env.ASTRO_PRO_DIR ?? join(homedir(), 'dev', 'astro-pro')
const date = process.argv[2] ?? new Date().toISOString().slice(0, 10)
const lib = (f) => pathToFileURL(join(ASTRO, 'src', 'lib', 'autonomie', f)).href
const { selectionner } = await import(lib('algo.js'))
const { configPourAxes } = await import(lib('axes-actifs.js'))
const { initConfig } = await import(lib('calibrage.js'))
const fx = new URL('../fixtures/', import.meta.url)
const lire = (f) => JSON.parse(readFileSync(new URL(f, fx), 'utf8'))
const options = lire('options.json')
const postes = lire('postes.json')
const climat = lire('france_climat.json')
const config = lire('algo-config.json')
const profils = lire('profils-reference.json')
initConfig(config)
const AXES = ['eau', 'energie', 'alim']
const resume = (r) => ({
total_eur_ht: r.total_eur_ht,
status: r.status,
ecart_budget_pct: r.ecart_budget_pct,
axes: Object.fromEntries(
AXES.map((a) => [
a,
{
budget_alloue: r.axes[a]?.budget_alloue ?? 0,
poids: r.axes[a]?.poids ?? 0,
sous_total_eur_ht: r.axes[a]?.sous_total_eur_ht ?? 0,
postes: (r.axes[a]?.postes ?? []).map((p) => ({
poste_id: p.poste_id,
typologie_choisie: p.typologie_choisie,
dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null,
cout_eur_ht: p.cout_eur_ht,
force_par_couplage: !!p.force_par_couplage,
})),
},
]),
),
couplages: r.couplages,
})
const sortie = {
_commentaire: `Sorties de selectionner() D'ORIGINE (astro-pro src/lib/autonomie, coefficients du code) sur les trois profils de référence, générées le ${date} par scripts/pin-origine.mjs. 'global' = pipeline complet ; 'par_axe' = mode un seul axe actif (configPourAxes) avec le budget que le pipeline global avait alloué à cet axe.`,
date,
profils: {},
}
for (const profil of profils) {
const global = selectionner(profil.inputs, options, postes, climat, config, false)
const par_axe = {}
for (const axe of AXES) {
const budget = global.axes[axe]?.budget_alloue ?? 0
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe]))
const r = selectionner({ ...profil.inputs, budget_eur: budget }, options, postes, climat, configPourAxes(config, actifs), false)
par_axe[axe] = { budget_axe: budget, ...resume(r) }
}
sortie.profils[profil.nom] = { inputs: profil.inputs, global: resume(global), par_axe }
}
const cible = new URL(`sorties-origine-${date}.json`, fx)
writeFileSync(cible, JSON.stringify(sortie, null, 2) + '\n', 'utf8')
console.log(`écrit : fixtures/sorties-origine-${date}.json`)
for (const [nom, p] of Object.entries(sortie.profils)) {
console.log(`${nom} : total ${p.global.total_eur_ht} (${p.global.status})`, AXES.map((a) => `${a}=${p.global.axes[a].postes.map((x) => x.typologie_choisie).join('+')}`).join(' | '))
}