Guide de transmission — ElectriCore
Pour : ingénieur Rust/Java, premier contact avec le projet. Objectif : comprendre ce que fait le code, comment le faire tourner, et comment s'y retrouver.
1. Contexte métier
Le problème
En France, la distribution d'électricité est gérée par Enedis (gestionnaire du réseau). Chaque point de livraison (PDL, le "compteur" Linky) produit des flux de données : relevés d'index, événements contractuels, factures réseau.
Ces flux sont transmis aux fournisseurs d'énergie en format XML/CSV propriétaire, appelés flux Enedis (R15, R151, C15, F15…). ElectriCore transforme ces fichiers bruts en données exploitables pour la facturation et l'analyse.
Ce que fait ElectriCore
Fichiers XML/CSV Enedis → Base DuckDB → Calculs métier → Odoo / API
(flux R15, C15…) (ingestion) (pipelines Polars) (résultats)
Concrètement, le pipeline de facturation calcule pour chaque PDL et chaque mois : - La puissance souscrite et sa durée (abonnement) - La consommation par cadran horaire (heures pleines/creuses) - Le TURPE : taxe réseau réglementaire (fixe + variable) - L'Accise (ancienne TICFE) : taxe intérieure sur l'électricité - La facturation mensuelle agrégée prête pour Odoo
2. Toolchain
Analogie Cargo → uv
Si tu viens de Rust, uv joue le même rôle que cargo :
| Cargo | uv | Rôle |
|---|---|---|
Cargo.toml |
pyproject.toml |
Manifeste du projet |
Cargo.lock |
uv.lock |
Lockfile déterministe |
cargo build |
uv build |
Build du package |
cargo test |
uv run --group test pytest |
Tests |
cargo run |
uv run python … |
Exécution |
[dev-dependencies] |
[dependency-groups] test |
Dépendances de dev |
Setup
# Prérequis : Python 3.12+, uv
uv sync # installe tout (équivalent : cargo build)
uv sync --extra ingestion # + dépendances SFTP pour l'ingestion (serveur de collecte)
# Vérifier que ça fonctionne
uv run --group test pytest -q
# → 671 passed, 35 skipped (juin 2026)
3. Architecture des modules
electricore/
├── ingestion/ # Collecte : SFTP Enedis → DuckDB (optionnel, --extra ingestion)
├── core/ # Calculs : DuckDB → résultats métier (ERP-agnostique, ADR-0016)
│ ├── loaders/ # Lecture des données (DuckDB, Parquet)
│ ├── pipelines/ # Transformations métier (Polars)
│ ├── builds/ # Livrables assemblés au boundary I/O (ADR-0019)
│ ├── models/ # Schémas de validation (Pandera)
│ └── writers/ # Écriture ERP-agnostique (vide pour l'instant)
├── integrations/ # Adaptateurs ERP — odoo/ (reader, writer, rapprochements)
├── api/ # API REST FastAPI (hub central, ADR-0009)
├── bot/ # Bot Telegram, client de l'API (ADR-0010, ADR-0022)
└── config/ # Fichiers CSV de règles tarifaires (TURPE, Accise, CTA)
Le core est le cœur : les pipelines transforment des données lues depuis DuckDB en résultats de facturation, sans effet de bord et sans dépendre d'aucun ERP.
4. Comprendre Polars (l'équivalent des iterators Rust)
Le code utilise Polars, une bibliothèque de traitement de données en colonnes. Les analogies avec Rust :
LazyFrame ≈ Iterator non-consommé / query plan
# Équivalent Rust : iter().filter(...).map(...) (pas encore exécuté)
lf = c15().filter({"pdl": ["PDL123"]}).lazy()
# .collect() = .collect::<Vec<_>>() → exécute et matérialise
df = lf.collect()
Un LazyFrame représente un plan de calcul qui n'est pas encore exécuté. Polars optimise ce plan (réordonne les filtres, fusionne les opérations) avant de l'exécuter. C'est l'équivalent d'un Iterator Rust paresseux, ou d'un Stream Java.
pl.Expr ≈ fonction composable
# Une expression = une transformation réutilisable sur une colonne
expr = pl.col("energie_hp_kwh") * pl.col("c_hp") / 100
# On l'applique via .with_columns() ou .select()
df.with_columns(expr.alias("turpe_hp"))
Les expressions sont pures et composables — comme des fonctions Rust qui prennent et retournent des valeurs sans muter l'état.
Exemple complet : calcul TURPE variable
# Dans electricore/core/pipelines/turpe.py
def expr_calculer_turpe_cadran(cadran: str) -> pl.Expr:
"""Retourne l'expression de calcul TURPE pour un cadran donné."""
return (
pl.when(pl.col(f"energie_{cadran}_kwh").is_not_null())
.then(pl.col(f"energie_{cadran}_kwh") * pl.col(f"c_{cadran}") / 100)
.otherwise(0.0)
)
# Usage
df.with_columns(
expr_calculer_turpe_cadran("hp").alias("turpe_hp_eur")
)
5. Flux de données complet
Les flux Enedis (entrées)
| Flux | Contenu | Table DuckDB |
|---|---|---|
| C15 | Événements contractuels (mise en service, résiliation, changement FTA…) | flux_c15 |
| R151 | Relevés périodiques Linky (index HP/HC/Base) | flux_r151 |
| R15 | Relevés à la demande + événements | flux_r15 |
| F15 | Factures réseau Enedis | flux_f15_detail |
Le pipeline de facturation
C15 (historique contractuel) R151/R64 (relevés d'index)
│ │
▼ │
pipeline_historique() │
→ détecte les ruptures de période │
→ enrichit avec les événements │
│ │
├──────────────────────────────────────┤
│ │
▼ ▼
pipeline_abonnements() pipeline_energie()
→ calcule durée de chaque → calcule consommation
période (nb_jours) par cadran (HP/HC/Base)
→ ajoute TURPE fixe → ajoute TURPE variable
│ │
└──────────────┬───────────────────────┘
▼
pipeline_facturation()
→ agrège par mois
→ méta-périodes mensuelles
→ validation Pandera
│
▼
ContexteMensuel
(dataclass frozen, 4 frames)
Appel du pipeline complet
from electricore.core.builds.contexte_mensuel import contexte_du_mois
# Entrée I/O : résout les loaders DuckDB et compose les pipelines
ctx = contexte_du_mois("2026-05-01") # None → dernier mois disponible
# Accéder aux résultats
df_mensuel = ctx.facturation_mensuelle # DataFrame déjà collecté
df_abos = ctx.abonnements.collect() # LazyFrame → DataFrame
ContexteMensuel est une dataclass frozen (≈ struct Rust immutable) :
@dataclass(frozen=True)
class ContexteMensuel:
mois: str
historique_enrichi: pl.LazyFrame
abonnements: pl.LazyFrame
energie: pl.LazyFrame
facturation_mensuelle: pl.DataFrame
Pour composer depuis des frames déjà chargés (tests, notebooks), utiliser
charger(historique_lf, releves_lf, mois=...) du même module.
6. Query Builders (lecture des données)
L'accès à DuckDB passe par des query builders immutables avec une API fluente :
from electricore.core.loaders import c15, r151, releves
# Équivalent d'un query builder SQL typé
historique = (
c15()
.filter({"pdl": ["PDL001", "PDL002"]})
.filter({"Date_Evenement": ">= '2024-01-01'"})
.limit(1000)
.collect() # → exécute la requête SQL sous-jacente
)
Chaque méthode retourne une nouvelle instance (immutable, comme les méthodes Rust sur Iterator). .collect() déclenche l'exécution.
7. Validation des données (Pandera)
Les pipelines critiques (facturation) utilisent Pandera pour valider les schémas :
# @pa.check_types valide les types des colonnes en entrée ET en sortie
@pa.check_types(lazy=True)
def pipeline_facturation(
abonnements: LazyFrame[PeriodeAbonnementModel],
energie: LazyFrame[PeriodeEnergieModel]
) -> DataFrame[ConsommationMensuelleModel]:
...
C'est l'équivalent d'une signature de type stricte avec validation à l'exécution — proche des traits Rust mais au niveau des colonnes du DataFrame.
8. Règles tarifaires (config CSV)
TURPE et Accise ne sont pas codés en dur : les taux sont dans des fichiers CSV avec des plages de validité temporelle.
electricore/config/
├── turpe_rules.csv # Taux TURPE par FTA (formule tarifaire) + période de validité
├── accise_rules.csv # Taux Accise par période (modification législative)
└── cta_rules.csv # Taux CTA par période
Les pipelines chargent ces règles via load_turpe_rules() / load_accise_rules() et font une jointure temporelle pour appliquer le bon taux selon la date de la période.
9. Domaine : glossaire
Le vocabulaire métier est éclaté par module : voir CONTEXT-MAP.md à la racine. L'essentiel (PDL, FTA, C5/C4, HP/HC, TURPE, accise, événements C15, périmètre, abonnement…) vit dans electricore/core/CONTEXT.md.
10. Tests et développement
# Lancer tous les tests
uv run --group test pytest -q
# Tests d'un module spécifique
uv run --group test pytest tests/unit/test_turpe.py -v
# Comprendre les marqueurs de test
# unit → tests unitaires purs, rapides
# integration → pipelines complets (certains nécessitent des données réelles)
# duckdb → nécessite la vraie base DuckDB locale
# skip_ci → données de prod, ne pas lancer en CI
État de la couverture
| Module | Tests |
|---|---|
historique, abonnements, energie, turpe |
✅ Complets |
facturation, accise, cta, taux, builds |
✅ Couverts |
api (endpoints), bot (handlers), ingestion (golden dbt) |
✅ Couverts |
| Architecture (pureté core, imports par rôle) | ✅ Tests exécutables |
Notebooks d'exploration
Des notebooks Marimo (interactifs, réactifs) sont disponibles dans notebooks/. Pour les lancer :
uv run marimo edit notebooks/
Marimo est à Python ce qu'un Jupyter notebook est à R — mais avec une réactivité garantie (recalcul automatique des cellules dépendantes).
11. Points d'entrée recommandés
Pour comprendre le code dans l'ordre :
- electricore/core/pipelines/historique.py — pipeline le plus simple, bien testé
- electricore/core/pipelines/turpe.py — expressions composables, bonne illustration du pattern
- electricore/core/builds/contexte_mensuel.py — comment tout s'assemble
- tests/unit/test_turpe.py — tests = documentation exécutable
- docs/conventions-dates-enedis.md — convention critique pour les dates