Aller au contenu principal

Fix: Filtrage des duplicats dans le simulateur

Date​

2025-11-22

Problème identifié​

Symptômes​

  • Montants calculés trop élevés dans le simulateur par rapport aux factures réelles
  • Les totaux affichés étaient significativement supérieurs aux montants attendus
  • Comparaison avec factures EDF/Enedis montrait des écarts importants

Cause racine​

Le code détectait les duplicats dans les données de consommation mais ne les filtrait pas avant les calculs.

// ❌ AVANT - Détection sans filtrage
const uniqueDates = new Set(allConsumption.map(item => item.date))
const hasDuplicates = uniqueDates.size !== allConsumption.length

if (hasDuplicates) {
logger.warn(`⚠️ DUPLICATE DETECTED: ${allConsumption.length - uniqueDates.size} duplicate points found!`)
}

// Les calculs utilisaient directement allConsumption AVEC les duplicats
const totalKwh = allConsumption.reduce((sum, item) => sum + (item.value / 1000), 0)

Impact​

Si par exemple il y avait 10% de duplicats dans les données (environ 1,750 points sur 17,500) :

  • Consommation réelle : 16,800 kWh
  • Consommation calculée avec duplicats : ~18,500 kWh (+10%)
  • Surcoût calculé : ~200-300€ selon l'offre

Solution implémentée​

Code corrigé​

// ✅ APRÈS - Filtrage des duplicats avant calculs
const uniqueDates = new Set(allConsumption.map(item => item.date))
const hasDuplicates = uniqueDates.size !== allConsumption.length

logger.log('Total consumption points (before deduplication):', allConsumption.length)
logger.log('Unique dates:', uniqueDates.size)
logger.log('Has duplicates?', hasDuplicates)

if (hasDuplicates) {
logger.warn(`⚠️ DUPLICATE DETECTED: ${allConsumption.length - uniqueDates.size} duplicate points found! Filtering duplicates...`)
}

// Filter duplicates: keep only first occurrence of each date
const seenDates = new Set<string>()
const dedupedConsumption = allConsumption.filter(item => {
if (seenDates.has(item.date)) {
return false // Skip duplicate
}
seenDates.add(item.date)
return true // Keep first occurrence
})

logger.log('Total consumption points (after deduplication):', dedupedConsumption.length)

// Use deduplicated data for calculations
const allConsumptionFinal = dedupedConsumption
const totalKwh = allConsumptionFinal.reduce((sum, item) => sum + (item.value / 1000), 0)

Changements dans tous les calculs​

Toutes les boucles forEach ont été mises à jour pour utiliser allConsumptionFinal :

  1. BASE / BASE_WEEKEND : allConsumptionFinal.forEach(...) (ligne 461)
  2. SEASONAL : allConsumptionFinal.forEach(...) (ligne 506)
  3. HC_HP / HC_NUIT_WEEKEND / HC_WEEKEND : allConsumptionFinal.forEach(...) (ligne 572)
  4. TEMPO : allConsumptionFinal.forEach(...) (ligne 665)

Pourquoi il y avait des duplicats ?​

Les duplicats proviennent de la stratégie de chevauchement des périodes (documentée dans simulator-consumption-calculation.md) :

Contexte​

  • L'API Enedis limite les requêtes à 7 jours maximum
  • En pratique, l'API retourne souvent 6 jours au lieu de 7
  • Pour éviter les jours manquants, les périodes se chevauchent d'1 jour

Exemple​

Période 1: 4 oct → 10 oct  (API retourne 4-9 oct = 6 jours)
Période 2: 10 oct → 16 oct (API retourne 10-15 oct = 6 jours)
↑
Le 10 octobre est présent dans les DEUX périodes

Impact normal​

  • 365 jours de données demandées
  • ~53 périodes avec chevauchement (365 / 7 + marges)
  • ~50-60 jours de duplicats en moyenne (1 jour par période)
  • Soit environ 2,400-2,880 points dupliqués (50-60 jours × 48 points/jour en PT30M)

Validation​

Comment vérifier la correction​

  1. Ouvrir la console du navigateur (F12)
  2. Lancer une simulation
  3. Vérifier les logs :
Total consumption points (before deduplication): 19872
Unique dates: 17424
Has duplicates? true
⚠️ DUPLICATE DETECTED: 2448 duplicate points found! Filtering duplicates...
Total consumption points (after deduplication): 17424
Total kWh for year: 16796

Points de contrôle​

✅ Avant déduplication > Après déduplication : Confirme que des duplicats ont été filtrés ✅ Après déduplication ≈ 365 jours × 48 points/jour : Cohérent avec PT30M (17,520 attendus) ✅ Total kWh cohérent avec factures : Comparer avec export CSV Enedis ou factures

Exemple de validation​

# Comparer avec les données Enedis
Simulateur (après fix): 16,796 kWh (4 oct 2024 → 2 oct 2025)
Factures EDF: 16,850 kWh (même période)
Écart: -54 kWh (-0.3%)

Un écart de moins de 1% est normal et acceptable.

Impact attendu​

Correction des montants​

Si avant la correction on avait +10% de duplicats :

OffreAvant (avec duplicats)Après (sans duplicats)Économie
BASE 12kVA (EDF)3,260€2,964€-296€
HC/HP 12kVA (EDF)3,180€2,891€-289€
TEMPO 12kVA (EDF)3,050€2,773€-277€

Les montants après correction doivent maintenant correspondre aux factures réelles.

Fichiers modifiés​

  • apps/web/src/pages/Simulator.tsx : Ajout du filtrage de duplicats (lignes 420-439)

Tests recommandés​

  1. Test manuel :

    • Aller sur /simulator
    • Sélectionner un PDL avec 365 jours de données en cache
    • Lancer une simulation
    • Vérifier les logs dans la console
    • Comparer les montants avec une facture réelle
  2. Test avec données de référence :

    • Télécharger le CSV depuis le site Enedis
    • Comparer le total kWh affiché dans le simulateur avec le CSV
    • L'écart doit être < 1%
  3. Test comparatif :

    • Utiliser un comparateur en ligne (ex: Hello Watt, Selectra)
    • Saisir la même consommation annuelle
    • Les montants calculés doivent être similaires (± 5%)

✅ Correction à la source implémentée (2025-11-22)​

Les duplicats sont maintenant éliminés à la source lors de la mise en cache !

Modification dans les hooks de fetch​

Fichiers modifiés :

  • apps/web/src/pages/Consumption/hooks/useConsumptionFetch.ts
  • apps/web/src/pages/Production/hooks/useProductionFetch.ts

Au lieu d'utiliser un tableau (any[]), on utilise maintenant un Map avec le timestamp comme clé :

// ✅ APRÈS - Déduplication automatique avec Map
const dataByDate: Record<string, Map<string, any>> = {}

readings.forEach((point: any) => {
// ... extraction de la date ...

if (!dataByDate[date]) {
dataByDate[date] = new Map()
}
// Use full timestamp as key to automatically deduplicate
dataByDate[date].set(point.date, point) // ← Clé unique = pas de duplicats
})

// Cache each day separately with deduplicated points
Object.entries(dataByDate).forEach(([date, pointsMap]) => {
// Convert Map values to array (automatically deduplicated)
const uniquePoints = Array.from(pointsMap.values())

queryClient.setQueryData(
['consumptionDetail', selectedPDL, date, date],
{
success: true,
data: {
meter_reading: {
interval_reading: uniquePoints // ← Points uniques garantis
}
}
}
)
})

Avantages de cette approche​

  1. Déduplication automatique : Le Map ne peut pas avoir deux clés identiques
  2. Performance : O(1) pour l'insertion au lieu de O(n) pour vérifier les doublons
  3. Simplicité : Pas besoin de logique de déduplication manuelle
  4. Garantie : Les duplicats sont impossibles structurellement

Résultat​

  • Même si on fetch plusieurs fois : Pas de duplicats dans le cache ✅
  • Chevauchement des périodes : Dernier point écrase le précédent ✅
  • Simulateur : Reçoit des données déjà propres ✅

Prochaines améliorations possibles​

  1. Déduplication en amont : Filtrer les duplicats lors du chargement du cache ✅ FAIT
  2. Métriques de qualité : Afficher dans l'UI le nombre de points chargés
  3. Tests unitaires : Ajouter des tests pour vérifier la déduplication
  4. Monitoring : Logger les cas où des duplicats sont détectés (pour debug)

Références​