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 :
- BASE / BASE_WEEKEND :
allConsumptionFinal.forEach(...)(ligne 461) - SEASONAL :
allConsumptionFinal.forEach(...)(ligne 506) - HC_HP / HC_NUIT_WEEKEND / HC_WEEKEND :
allConsumptionFinal.forEach(...)(ligne 572) - 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
- Ouvrir la console du navigateur (F12)
- Lancer une simulation
- 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 :
| Offre | Avant (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
-
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
- Aller sur
-
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%
-
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.tsapps/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
- Déduplication automatique : Le Map ne peut pas avoir deux clés identiques
- Performance : O(1) pour l'insertion au lieu de O(n) pour vérifier les doublons
- Simplicité : Pas besoin de logique de déduplication manuelle
- 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
Déduplication en amont : Filtrer les duplicats lors du chargement du cache✅ FAIT- Métriques de qualité : Afficher dans l'UI le nombre de points chargés
- Tests unitaires : Ajouter des tests pour vérifier la déduplication
- Monitoring : Logger les cas où des duplicats sont détectés (pour debug)
Références
- Documentation initiale : Problèmes 1-3
- Spécification du simulateur
- Code source