Skip to content

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 :

  1. electricore/core/pipelines/historique.py — pipeline le plus simple, bien testé
  2. electricore/core/pipelines/turpe.py — expressions composables, bonne illustration du pattern
  3. electricore/core/builds/contexte_mensuel.py — comment tout s'assemble
  4. tests/unit/test_turpe.py — tests = documentation exécutable
  5. docs/conventions-dates-enedis.md — convention critique pour les dates